From 6db83aebcdb3c815b00f1d8948d6c79c2fb94a0b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 10:59:27 +0200 Subject: [PATCH 001/378] Introduce ExpressionResultFactory --- .../ExprHandler/ArrayDimFetchHandler.php | 9 +++++-- src/Analyser/ExprHandler/ArrayHandler.php | 4 +++- .../ExprHandler/ArrowFunctionHandler.php | 4 +++- src/Analyser/ExprHandler/AssignHandler.php | 12 ++++++---- src/Analyser/ExprHandler/AssignOpHandler.php | 8 ++++--- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 +++- .../ExprHandler/BitwiseNotHandler.php | 4 +++- .../ExprHandler/BooleanAndHandler.php | 4 +++- .../ExprHandler/BooleanNotHandler.php | 7 +++++- src/Analyser/ExprHandler/BooleanOrHandler.php | 4 +++- src/Analyser/ExprHandler/CastHandler.php | 4 +++- .../ExprHandler/CastStringHandler.php | 4 +++- .../ExprHandler/ClassConstFetchHandler.php | 4 +++- src/Analyser/ExprHandler/CloneHandler.php | 7 +++++- src/Analyser/ExprHandler/ClosureHandler.php | 4 +++- src/Analyser/ExprHandler/CoalesceHandler.php | 4 +++- .../ExprHandler/ConstFetchHandler.php | 4 +++- src/Analyser/ExprHandler/EmptyHandler.php | 4 +++- .../ExprHandler/ErrorSuppressHandler.php | 7 +++++- src/Analyser/ExprHandler/EvalHandler.php | 7 +++++- src/Analyser/ExprHandler/ExitHandler.php | 7 +++++- src/Analyser/ExprHandler/FuncCallHandler.php | 4 +++- .../Helper/ImplicitToStringCallHelper.php | 6 +++-- src/Analyser/ExprHandler/IncludeHandler.php | 7 +++++- .../ExprHandler/InstanceofHandler.php | 7 +++++- .../ExprHandler/InterpolatedStringHandler.php | 4 +++- src/Analyser/ExprHandler/IssetHandler.php | 4 +++- src/Analyser/ExprHandler/MatchHandler.php | 4 +++- .../ExprHandler/MethodCallHandler.php | 6 +++-- src/Analyser/ExprHandler/NewHandler.php | 4 +++- .../ExprHandler/NullsafeMethodCallHandler.php | 4 +++- .../NullsafePropertyFetchHandler.php | 4 +++- src/Analyser/ExprHandler/PipeHandler.php | 7 +++++- src/Analyser/ExprHandler/PostDecHandler.php | 7 +++++- src/Analyser/ExprHandler/PostIncHandler.php | 7 +++++- src/Analyser/ExprHandler/PreDecHandler.php | 7 +++++- src/Analyser/ExprHandler/PreIncHandler.php | 7 +++++- src/Analyser/ExprHandler/PrintHandler.php | 4 +++- .../ExprHandler/PropertyFetchHandler.php | 4 +++- src/Analyser/ExprHandler/ScalarHandler.php | 4 +++- .../ExprHandler/StaticCallHandler.php | 4 +++- .../StaticPropertyFetchHandler.php | 4 +++- src/Analyser/ExprHandler/TernaryHandler.php | 4 +++- src/Analyser/ExprHandler/ThrowHandler.php | 7 +++++- .../ExprHandler/UnaryMinusHandler.php | 4 +++- src/Analyser/ExprHandler/UnaryPlusHandler.php | 4 +++- src/Analyser/ExprHandler/VariableHandler.php | 7 +++++- .../Virtual/AlwaysRememberedExprHandler.php | 7 +++++- .../Virtual/ExistingArrayDimFetchHandler.php | 7 +++++- .../Virtual/FunctionCallableNodeHandler.php | 7 +++++- .../Virtual/GetIterableKeyTypeExprHandler.php | 7 +++++- .../GetIterableValueTypeExprHandler.php | 7 +++++- .../Virtual/GetOffsetValueTypeExprHandler.php | 7 +++++- .../InstantiationCallableNodeHandler.php | 7 +++++- .../Virtual/MethodCallableNodeHandler.php | 7 +++++- .../Virtual/NativeTypeExprHandler.php | 7 +++++- .../OriginalPropertyTypeExprHandler.php | 4 +++- .../SetExistingOffsetValueTypeExprHandler.php | 7 +++++- .../Virtual/SetOffsetValueTypeExprHandler.php | 7 +++++- .../StaticMethodCallableNodeHandler.php | 7 +++++- .../ExprHandler/Virtual/TypeExprHandler.php | 7 +++++- .../Virtual/UnsetOffsetExprHandler.php | 7 +++++- src/Analyser/ExprHandler/YieldFromHandler.php | 7 +++++- src/Analyser/ExprHandler/YieldHandler.php | 7 +++++- src/Analyser/ExpressionResult.php | 3 +++ src/Analyser/ExpressionResultFactory.php | 24 +++++++++++++++++++ src/Analyser/NodeScopeResolver.php | 11 +++++---- src/Testing/RuleTestCase.php | 2 ++ src/Testing/TypeInferenceTestCase.php | 2 ++ tests/PHPStan/Analyser/AnalyserTest.php | 1 + .../Fiber/FiberNodeScopeResolverRuleTest.php | 2 ++ .../Fiber/FiberNodeScopeResolverTest.php | 2 ++ 72 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 src/Analyser/ExpressionResultFactory.php diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 1c389f9fb9a..f82489c876b 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -11,6 +11,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -35,6 +36,10 @@ final class ArrayDimFetchHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ArrayDimFetch; @@ -80,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), @@ -109,7 +114,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->getThrowPoints()); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index f64a168e848..580ab998b6c 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -10,6 +10,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -37,6 +38,7 @@ final class ArrayHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -98,7 +100,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 0cdfddf675d..d4306247c81 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; @@ -28,6 +29,7 @@ final class ArrowFunctionHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,7 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $result = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); - return new ExpressionResult( + return $this->expressionResultFactory->create( $result->getScope(), hasYield: $result->hasYield(), isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 3a662bcbf73..cc979b36115 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -25,6 +25,7 @@ use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; @@ -95,6 +96,7 @@ public function __construct( private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -298,7 +300,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { $impurePoints = []; if ($expr instanceof AssignRef) { $referencedExpr = $expr->expr; @@ -338,7 +340,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $scope = $scope->exitExpressionAssign($expr->expr); } - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); }, true, ); @@ -380,7 +382,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), @@ -946,7 +948,7 @@ public function processAssignVar( new GetOffsetValueTypeExpr($assignedExpr, $dimExpr), $nodeCallback, $context, - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -1038,7 +1040,7 @@ public function processAssignVar( } // stored where processAssignVar is called - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } private function createArrayDimFetchConditionalExpressionHolder( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 6208008ee9c..10d903806e4 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -11,6 +11,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; @@ -43,6 +44,7 @@ public function __construct( private AssignHandler $assignHandler, private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -63,7 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { $scope = $scope->filterByFalseyValue( @@ -82,7 +84,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex if ($expr instanceof Expr\AssignOp\Coalesce) { $nodeScopeResolver->storeBeforeScope($storage, $expr, $originalScope); $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), $exprResult->hasYield(), $isAlwaysTerminating, @@ -113,7 +115,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 0f604c86441..4c37485323d 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -14,6 +14,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; @@ -66,6 +67,7 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -101,7 +103,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $scope = $rightResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index de49fb09887..08ea1a0c1ed 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -28,6 +29,7 @@ final class BitwiseNotHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,7 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 383b03c7d66..5727c8cfdf5 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -10,6 +10,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; @@ -44,6 +45,7 @@ final class BooleanAndHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -271,7 +273,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $leftMergedWithRightScope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 6bd2831fb27..082615dc3d5 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -27,6 +28,10 @@ final class BooleanNotHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof BooleanNot; @@ -37,7 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 77f7744702b..75df2f465a3 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; @@ -44,6 +45,7 @@ final class BooleanOrHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -349,7 +351,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $leftMergedWithRightScope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index cbc3d70fcb0..f7837085055 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -35,6 +36,7 @@ final class CastHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -49,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 4fdfc9adfc6..e8a4a3c9057 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; @@ -33,6 +34,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -54,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 25199ed14f7..61e28d1cac5 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,7 @@ final class ClassConstFetchHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -83,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 9d2f1bf6574..78534ceb1c7 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -29,6 +30,10 @@ final class CloneHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Clone_; @@ -38,7 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index cc70889d379..0961cf29c98 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; @@ -28,6 +29,7 @@ final class ClosureHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -42,7 +44,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); $scope = $processClosureResult->applyByRefUseScope($processClosureResult->getScope()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 5e5d3f40fa9..1d06a71694b 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; @@ -34,6 +35,7 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -132,7 +134,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index ba5ae2c71e1..6d825348e16 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ConstantResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -33,6 +34,7 @@ final class ConstFetchHandler implements ExprHandler public function __construct( private ConstantResolver $constantResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -46,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $nodeScopeResolver->callNodeCallback($nodeCallback, $expr->name, $scope, $storage); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 185850e6e6f..cba551bfd36 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; @@ -32,6 +33,7 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -92,7 +94,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 0e2e47fee9c..1189f6324a7 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class ErrorSuppressHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ErrorSuppress; @@ -34,7 +39,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index cb9fe76a273..7c105ad9c69 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -29,6 +30,10 @@ final class EvalHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Eval_; @@ -44,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope()->invalidateVolatileExpressions(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 7734d8dea34..a2d4a7db273 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -28,6 +29,10 @@ final class ExitHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Exit_; @@ -51,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $exprResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: true, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 47efb2af7fe..33980ebda67 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -16,6 +16,7 @@ use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; @@ -96,6 +97,7 @@ public function __construct( private bool $implicitThrows, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -585,7 +587,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->invalidateVolatileExpressions(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index eab62846f88..4ef762ad16d 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -6,6 +6,7 @@ use PhpParser\Node\Identifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; @@ -19,6 +20,7 @@ final class ImplicitToStringCallHelper public function __construct( private PhpVersion $phpVersion, private MethodThrowPointHelper $methodThrowPointHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -35,7 +37,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); } if ($toStringMethod === null) { - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, @@ -67,7 +69,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 1a26081d332..25f06059e3e 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -30,6 +31,10 @@ final class IncludeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Include_; @@ -46,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $identifier = in_array($expr->type, [Include_::TYPE_INCLUDE, Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require'; $scope = $exprResult->getScope()->afterExtractCall()->invalidateVolatileExpressions(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 36933e466b3..0c1007743f8 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -38,6 +39,10 @@ final class InstanceofHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Instanceof_; @@ -60,7 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $classResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 51de44f579f..3e8672a012f 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; @@ -33,6 +34,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -65,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $partResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index da8e48431fd..a2becee723f 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -15,6 +15,7 @@ use PhpParser\Node\VarLikeIdentifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; @@ -59,6 +60,7 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -385,7 +387,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 90d4714a5af..b7a0a4b7696 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -16,6 +16,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\InternalThrowPoint; @@ -57,6 +58,7 @@ final class MatchHandler implements ExprHandler public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -501,7 +503,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->cond = $expr->cond->getExpr(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 65fc78781cf..1736af85818 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; @@ -60,6 +61,7 @@ public function __construct( private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -198,7 +200,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - $result = new ExpressionResult( + $result = $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, @@ -226,7 +228,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $calledMethodScope = $nodeScopeResolver->processCalledMethod($methodReflection); if ($calledMethodScope !== null) { $scope = $scope->mergeInitializedProperties($calledMethodScope); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 111fe38a682..0b28ea11e89 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -78,6 +79,7 @@ public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -224,7 +226,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->invalidateVolatileExpressions(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 864a4859275..d0ec6d41ca8 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -12,6 +12,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; @@ -37,6 +38,7 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -115,7 +117,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 0c9e190ff47..8ec6531c115 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -12,6 +12,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; @@ -37,6 +38,7 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -94,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 93bd3554ef7..4ff195222bd 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -11,6 +11,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -32,6 +33,10 @@ final class PipeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Pipe; @@ -84,7 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $callResult = $nodeScopeResolver->processExprNode($stmt, $callExpr, $scope, $storage, $nodeCallback, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $callResult->getScope(), hasYield: $callResult->hasYield(), isAlwaysTerminating: $callResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 6c38de4a62e..1714b1d3ff2 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -26,6 +27,10 @@ final class PostDecHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostDec; @@ -44,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index f26c45c50fe..4f9f304e30c 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -26,6 +27,10 @@ final class PostIncHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostInc; @@ -44,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 4abde925a80..f79b754181a 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -40,6 +41,10 @@ final class PreDecHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreDec; @@ -107,7 +112,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 1750b876542..f3504623cca 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -41,6 +42,10 @@ final class PreIncHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreInc; @@ -108,7 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 9d8b220ccfe..9a3b61a14d3 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; @@ -31,6 +32,7 @@ final class PrintHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -57,7 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index bfe79639138..c39a6786268 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -40,6 +41,7 @@ final class PropertyFetchHandler implements ExprHandler public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -81,7 +83,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index abd0dc63a4f..6f0db0d86f5 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -30,6 +31,7 @@ final class ScalarHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,7 +43,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 16b72ce52c9..0463634f465 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; @@ -68,6 +69,7 @@ public function __construct( private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -286,7 +288,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 9560761f3c9..374b24755c8 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -11,6 +11,7 @@ use PhpParser\Node\VarLikeIdentifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -40,6 +41,7 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -80,7 +82,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $nameResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 3122a02e23c..4c7fe9388f1 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -33,6 +34,7 @@ final class TernaryHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -148,7 +150,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $finalScope, hasYield: $hasYield, isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 63c9b4720e8..7c283decba4 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\InternalThrowPoint; @@ -28,6 +29,10 @@ final class ThrowHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Throw_; @@ -37,7 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()->enterThrow()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: true, diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 2bc2d872380..0da8e3d46a4 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -28,6 +29,7 @@ final class UnaryMinusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,7 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 676110b904f..3f2c6bcbbf9 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -28,6 +29,7 @@ final class UnaryPlusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,7 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index e104b9fed42..f2c6f414207 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -34,6 +35,10 @@ final class VariableHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Variable; @@ -89,7 +94,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, $hasYield, $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index fcf77547e33..8e26801f1e0 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class AlwaysRememberedExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof AlwaysRememberedExpr; @@ -44,7 +49,7 @@ public function processExpr( $innerResult = $nodeScopeResolver->processExprNode($stmt, $innerExpr, $scope, $storage, $nodeCallback, $context); $scope = $innerResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $innerResult->hasYield(), isAlwaysTerminating: $innerResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index c2bf0d37fab..071f56be856 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class ExistingArrayDimFetchHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ExistingArrayDimFetch; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index b358f70c8df..4b91658cc82 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -26,6 +27,10 @@ final class FunctionCallableNodeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof FunctionCallableNode; @@ -46,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php index a9de984485e..241122ccf84 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class GetIterableKeyTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof GetIterableKeyTypeExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php index 261c364ffd3..040732cfac3 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class GetIterableValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof GetIterableValueTypeExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php index 09922c7daa7..539ecc7a3b1 100644 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class GetOffsetValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof GetOffsetValueTypeExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index eb552e3a544..3ce468022a9 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -26,6 +27,10 @@ final class InstantiationCallableNodeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof InstantiationCallableNode; @@ -46,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index f2d224bc91a..c94173c354f 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -27,6 +28,10 @@ final class MethodCallableNodeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof MethodCallableNode; @@ -49,7 +54,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index c952fcc1297..e916eb9ec69 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class NativeTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof NativeTypeExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php index 7893990b1a3..ea08496e209 100644 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -29,6 +30,7 @@ final class OriginalPropertyTypeExprHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -43,7 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index cd764c40dbf..5c986631ba1 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -27,6 +28,10 @@ final class SetExistingOffsetValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof SetExistingOffsetValueTypeExpr; @@ -37,7 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 92c41c6b516..a8ffc98a0bb 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -27,6 +28,10 @@ final class SetOffsetValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof SetOffsetValueTypeExpr; @@ -37,7 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 10467a171a5..0eef43563b0 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -27,6 +28,10 @@ final class StaticMethodCallableNodeHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof StaticMethodCallableNode; @@ -55,7 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 81c6c9f08f8..53766b68323 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class TypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof TypeExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 0c2c4741831..995219a4f05 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -6,6 +6,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; @@ -25,6 +26,10 @@ final class UnsetOffsetExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof UnsetOffsetExpr; @@ -35,7 +40,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 7b86f00abb2..9e148f388f7 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -31,6 +32,10 @@ final class YieldFromHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof YieldFrom; @@ -52,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: true, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 48fb166ee70..583907a17b6 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; @@ -31,6 +32,10 @@ final class YieldHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Yield_; @@ -82,7 +87,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: true, isAlwaysTerminating: $isAlwaysTerminating, diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 746c518953d..42e49e004aa 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,6 +2,9 @@ namespace PHPStan\Analyser; +use PHPStan\DependencyInjection\GenerateFactory; + +#[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult { diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php new file mode 100644 index 00000000000..f724e6fdcb4 --- /dev/null +++ b/src/Analyser/ExpressionResultFactory.php @@ -0,0 +1,24 @@ +expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, hasYield: false, isAlwaysTerminating: false, @@ -3169,7 +3170,7 @@ public function processArrowFunctionNode( $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return new ExpressionResult($scope, false, $exprResult->isAlwaysTerminating(), $exprResult->getThrowPoints(), $exprResult->getImpurePoints()); + return $this->expressionResultFactory->create($scope, false, $exprResult->isAlwaysTerminating(), $exprResult->getThrowPoints(), $exprResult->getImpurePoints()); } /** @@ -3887,7 +3888,7 @@ public function processArgs( } // not storing this, it's scope after processing all args - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } /** @@ -4039,7 +4040,7 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), false, ); } diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 7f137660159..0a67df7cd7a 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\FileAnalyser; @@ -119,6 +120,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ExpressionResultFactory::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 7723560fd06..3cda273ecf0 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\MutatingScope; @@ -93,6 +94,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 6f781f28a25..b666e98cf31 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -835,6 +835,7 @@ private function createAnalyser(): Analyser true, $this->shouldTreatPhpDocTypesAsCertain(), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 0a1804341a6..fae07c69bb5 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -138,6 +139,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ExpressionResultFactory::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index ae9aa1ec4c1..608bfee99ec 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\Fiber; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; @@ -71,6 +72,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); } From df2e035fcd7da343dfb1afc0cb7d9193caf04ad8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 11:28:09 +0200 Subject: [PATCH 002/378] ExpressionResult - add beforeScope --- .../ExprHandler/ArrayDimFetchHandler.php | 3 +++ src/Analyser/ExprHandler/ArrayHandler.php | 2 ++ .../ExprHandler/ArrowFunctionHandler.php | 1 + src/Analyser/ExprHandler/AssignHandler.php | 10 +++++++--- src/Analyser/ExprHandler/AssignOpHandler.php | 3 +++ src/Analyser/ExprHandler/BinaryOpHandler.php | 2 ++ .../ExprHandler/BitwiseNotHandler.php | 1 + .../ExprHandler/BooleanAndHandler.php | 1 + .../ExprHandler/BooleanNotHandler.php | 2 ++ src/Analyser/ExprHandler/BooleanOrHandler.php | 1 + src/Analyser/ExprHandler/CastHandler.php | 2 ++ .../ExprHandler/CastStringHandler.php | 2 ++ .../ExprHandler/ClassConstFetchHandler.php | 2 ++ src/Analyser/ExprHandler/CloneHandler.php | 1 + src/Analyser/ExprHandler/ClosureHandler.php | 4 ++-- src/Analyser/ExprHandler/CoalesceHandler.php | 2 ++ .../ExprHandler/ConstFetchHandler.php | 1 + src/Analyser/ExprHandler/EmptyHandler.php | 2 ++ .../ExprHandler/ErrorSuppressHandler.php | 1 + src/Analyser/ExprHandler/EvalHandler.php | 2 ++ src/Analyser/ExprHandler/ExitHandler.php | 2 ++ src/Analyser/ExprHandler/FuncCallHandler.php | 2 ++ .../Helper/ImplicitToStringCallHelper.php | 2 ++ src/Analyser/ExprHandler/IncludeHandler.php | 2 ++ .../ExprHandler/InstanceofHandler.php | 2 ++ .../ExprHandler/InterpolatedStringHandler.php | 2 ++ src/Analyser/ExprHandler/IssetHandler.php | 2 ++ src/Analyser/ExprHandler/MatchHandler.php | 2 ++ .../ExprHandler/MethodCallHandler.php | 3 +++ src/Analyser/ExprHandler/NewHandler.php | 2 ++ .../ExprHandler/NullsafeMethodCallHandler.php | 2 ++ .../NullsafePropertyFetchHandler.php | 2 ++ src/Analyser/ExprHandler/PipeHandler.php | 1 + src/Analyser/ExprHandler/PostDecHandler.php | 19 +++++++++---------- src/Analyser/ExprHandler/PostIncHandler.php | 19 +++++++++---------- src/Analyser/ExprHandler/PreDecHandler.php | 19 +++++++++---------- src/Analyser/ExprHandler/PreIncHandler.php | 19 +++++++++---------- src/Analyser/ExprHandler/PrintHandler.php | 2 ++ .../ExprHandler/PropertyFetchHandler.php | 2 ++ src/Analyser/ExprHandler/ScalarHandler.php | 1 + .../ExprHandler/StaticCallHandler.php | 2 ++ .../StaticPropertyFetchHandler.php | 2 ++ src/Analyser/ExprHandler/TernaryHandler.php | 1 + src/Analyser/ExprHandler/ThrowHandler.php | 1 + .../ExprHandler/UnaryMinusHandler.php | 1 + src/Analyser/ExprHandler/UnaryPlusHandler.php | 1 + src/Analyser/ExprHandler/VariableHandler.php | 2 ++ .../Virtual/AlwaysRememberedExprHandler.php | 2 ++ .../Virtual/ExistingArrayDimFetchHandler.php | 1 + .../Virtual/FunctionCallableNodeHandler.php | 2 ++ .../Virtual/GetIterableKeyTypeExprHandler.php | 1 + .../GetIterableValueTypeExprHandler.php | 1 + .../Virtual/GetOffsetValueTypeExprHandler.php | 1 + .../InstantiationCallableNodeHandler.php | 2 ++ .../Virtual/MethodCallableNodeHandler.php | 2 ++ .../Virtual/NativeTypeExprHandler.php | 1 + .../OriginalPropertyTypeExprHandler.php | 1 + .../SetExistingOffsetValueTypeExprHandler.php | 1 + .../Virtual/SetOffsetValueTypeExprHandler.php | 1 + .../StaticMethodCallableNodeHandler.php | 2 ++ .../ExprHandler/Virtual/TypeExprHandler.php | 1 + .../Virtual/UnsetOffsetExprHandler.php | 1 + src/Analyser/ExprHandler/YieldFromHandler.php | 2 ++ src/Analyser/ExprHandler/YieldHandler.php | 2 ++ src/Analyser/ExpressionResult.php | 6 ++++++ src/Analyser/ExpressionResultFactory.php | 1 + src/Analyser/NodeScopeResolver.php | 9 +++++---- 67 files changed, 153 insertions(+), 49 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index f82489c876b..59ea71f1e31 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -81,12 +81,14 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; if ($expr->dim === null) { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), @@ -116,6 +118,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 580ab998b6c..78eb2b5491d 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -74,6 +74,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $itemNodes = []; $hasYield = false; $throwPoints = []; @@ -102,6 +103,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index d4306247c81..b98fc9c85de 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $result->getScope(), + beforeScope: $scope, hasYield: $result->hasYield(), isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index cc979b36115..370c79db30c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -291,6 +291,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -301,6 +302,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, $context, function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + $beforeScope = $scope; $impurePoints = []; if ($expr instanceof AssignRef) { $referencedExpr = $expr->expr; @@ -340,7 +342,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $scope = $scope->exitExpressionAssign($expr->expr); } - return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); }, true, ); @@ -384,6 +386,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), @@ -410,6 +413,7 @@ public function processAssignVar( bool $enterExpressionAssign, ): ExpressionResult { + $beforeScope = $scope; $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; @@ -948,7 +952,7 @@ public function processAssignVar( new GetOffsetValueTypeExpr($assignedExpr, $dimExpr), $nodeCallback, $context, - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -1040,7 +1044,7 @@ public function processAssignVar( } // stored where processAssignVar is called - return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } private function createArrayDimFetchConditionalExpressionHolder( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 10d903806e4..7bc92d78d3d 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -56,6 +56,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $assignResult = $this->assignHandler->processAssignVar( $nodeScopeResolver, $scope, @@ -86,6 +87,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), + $originalScope, $exprResult->hasYield(), $isAlwaysTerminating, $exprResult->getThrowPoints(), @@ -117,6 +119,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 4c37485323d..70d7841012a 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -85,6 +85,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftResult->getScope(), $storage, $nodeCallback, $context->enterDeep()); $throwPoints = array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()); @@ -105,6 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 08ea1a0c1ed..ae9ec208bd5 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 5727c8cfdf5..d96b8616595 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -275,6 +275,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $leftMergedWithRightScope, + beforeScope: $scope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 082615dc3d5..08091ce1dc0 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -39,11 +39,13 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 75df2f465a3..6099acd215d 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -353,6 +353,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $leftMergedWithRightScope, + beforeScope: $scope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index f7837085055..99085f9b800 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -48,11 +48,13 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index e8a4a3c9057..32f31ec60a1 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -46,6 +46,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); @@ -58,6 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 61e28d1cac5..3283d8f4153 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -58,6 +58,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -87,6 +88,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 78534ceb1c7..ee1951e68d5 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 0961cf29c98..e0e742e812d 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -42,10 +42,10 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); - $scope = $processClosureResult->applyByRefUseScope($processClosureResult->getScope()); return $this->expressionResultFactory->create( - $scope, + $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 1d06a71694b..0beda07a2a6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -119,6 +119,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); @@ -136,6 +137,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 6d825348e16..23510dbbaba 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -50,6 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index cba551bfd36..d5f932b2b14 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -87,6 +87,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -96,6 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 1189f6324a7..700d260261a 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -41,6 +41,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 7c105ad9c69..8d234e2ee68 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -46,11 +46,13 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope()->invalidateVolatileExpressions(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index a2d4a7db273..0cba81da42c 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -40,6 +40,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $kind = $expr->getAttribute('kind', Exit_::KIND_EXIT); $identifier = $kind === Exit_::KIND_DIE ? 'die' : 'exit'; $impurePoints = [ @@ -58,6 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: true, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 33980ebda67..346b6eba7a4 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -109,6 +109,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $parametersAcceptor = null; $functionReflection = null; $throwPoints = []; @@ -589,6 +590,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 4ef762ad16d..9303995b032 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -39,6 +39,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E if ($toStringMethod === null) { return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], @@ -71,6 +72,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 25f06059e3e..024b6ab5e84 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -47,12 +47,14 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $identifier = in_array($expr->type, [Include_::TYPE_INCLUDE, Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require'; $scope = $exprResult->getScope()->afterExtractCall()->invalidateVolatileExpressions(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 0c1007743f8..077e965ffdc 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -50,6 +50,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $exprResult->hasYield(); $throwPoints = $exprResult->getThrowPoints(); @@ -67,6 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 3e8672a012f..df25ce12114 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -46,6 +46,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -69,6 +70,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index a2becee723f..001ee84f127 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -346,6 +346,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -389,6 +390,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index b7a0a4b7696..6f794d430fe 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -209,6 +209,7 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $deepContext = $context->enterDeep(); $condType = $scope->getType($expr->cond); $condNativeType = $scope->getNativeType($expr->cond); @@ -505,6 +506,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 1736af85818..3d4b7617f95 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -73,6 +73,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $originalScope = $scope; if ( ($expr->var instanceof Expr\Closure || $expr->var instanceof Expr\ArrowFunction) @@ -202,6 +203,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $result = $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, @@ -230,6 +232,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeInitializedProperties($calledMethodScope); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 0b28ea11e89..56cc73980a1 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -91,6 +91,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $parametersAcceptor = null; $constructorReflection = null; $classReflection = null; @@ -228,6 +229,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index d0ec6d41ca8..05a94ad2668 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -86,6 +86,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $scopeBeforeNullsafe = $scope; $varType = $scope->getType($expr->var); @@ -119,6 +120,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 8ec6531c115..d9df195fd37 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -86,6 +86,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); @@ -98,6 +99,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 4ff195222bd..e6d010e2bf4 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -91,6 +91,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $callResult->getScope(), + beforeScope: $scope, hasYield: $callResult->hasYield(), isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 1714b1d3ff2..c28a4a0175e 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -40,17 +40,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - new PreDec($expr->var), - $nodeCallback, - )->getScope(); - return $this->expressionResultFactory->create( - $scope, + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + new PreDec($expr->var), + $nodeCallback, + )->getScope(), + beforeScope: $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 4f9f304e30c..f54fa7d4e0a 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -40,17 +40,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - new PreInc($expr->var), - $nodeCallback, - )->getScope(); - return $this->expressionResultFactory->create( - $scope, + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + new PreInc($expr->var), + $nodeCallback, + )->getScope(), + beforeScope: $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index f79b754181a..344dde7a6ad 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -103,17 +103,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - $expr, - $nodeCallback, - )->getScope(); - return $this->expressionResultFactory->create( - $scope, + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + $expr, + $nodeCallback, + )->getScope(), + beforeScope: $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index f3504623cca..9e6d026f101 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -104,17 +104,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - $expr, - $nodeCallback, - )->getScope(); - return $this->expressionResultFactory->create( - $scope, + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + $expr, + $nodeCallback, + )->getScope(), + beforeScope: $scope, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 9a3b61a14d3..d495f8b63ca 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -49,6 +49,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); @@ -61,6 +62,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index c39a6786268..2bd9c8de439 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -53,6 +53,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $scopeBeforeVar = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $varResult->hasYield(); @@ -85,6 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 6f0db0d86f5..ade6dc84023 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 0463634f465..e60738c73e0 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -81,6 +81,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -290,6 +291,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 374b24755c8..a5f51a3017c 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -53,6 +53,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = [ @@ -84,6 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 4c7fe9388f1..497ba5e7f8b 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -152,6 +152,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $finalScope, + beforeScope: $scope, hasYield: $hasYield, isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 7c283decba4..2cf4666b6bd 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -44,6 +44,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: true, throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 0da8e3d46a4..d97068c3acb 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 3f2c6bcbbf9..e2450452e31 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index f2c6f414207..3fdbdd0544a 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -78,6 +78,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -96,6 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } return $this->expressionResultFactory->create( $scope, + $beforeScope, $hasYield, $isAlwaysTerminating, $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 8e26801f1e0..7a75e944ebc 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -45,12 +45,14 @@ public function processExpr( ExpressionContext $context, ): ExpressionResult { + $beforeScope = $scope; $innerExpr = $expr->getExpr(); $innerResult = $nodeScopeResolver->processExprNode($stmt, $innerExpr, $scope, $storage, $nodeCallback, $context); $scope = $innerResult->getScope(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $innerResult->hasYield(), isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 071f56be856..02d15c413b8 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index 4b91658cc82..8f3aaf827c2 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -38,6 +38,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = []; $impurePoints = []; $hasYield = false; @@ -53,6 +54,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php index 241122ccf84..4d5f0e54a82 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php index 040732cfac3..5b5b59ee994 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php index 539ecc7a3b1..03f846910c4 100644 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 3ce468022a9..dddc6e216c9 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -38,6 +38,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = []; $impurePoints = []; $hasYield = false; @@ -53,6 +54,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index c94173c354f..39b370ed581 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -39,6 +39,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $scope = $varResult->getScope(); $hasYield = $varResult->hasYield(); @@ -56,6 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index e916eb9ec69..0b7999acb50 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php index ea08496e209..9f36aee736c 100644 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php @@ -47,6 +47,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 5c986631ba1..7c7f2847d29 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -44,6 +44,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index a8ffc98a0bb..e604dece482 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -44,6 +44,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 0eef43563b0..34e7dbf25b7 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -39,6 +39,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = []; $impurePoints = []; $hasYield = false; @@ -62,6 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 53766b68323..26b05213a50 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 995219a4f05..1246378b936 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 9e148f388f7..089ce51b630 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -54,11 +54,13 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: true, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 583907a17b6..76356bfbf7b 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -59,6 +59,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = [ InternalThrowPoint::createImplicit($scope, $expr), ]; @@ -89,6 +90,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, hasYield: true, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 42e49e004aa..c75c3b3ac33 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -26,6 +26,7 @@ final class ExpressionResult */ public function __construct( private MutatingScope $scope, + private MutatingScope $beforeScope, private bool $hasYield, private bool $isAlwaysTerminating, private array $throwPoints, @@ -43,6 +44,11 @@ public function getScope(): MutatingScope return $this->scope; } + public function getBeforeScope(): MutatingScope + { + return $this->beforeScope; + } + public function hasYield(): bool { return $this->hasYield; diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index f724e6fdcb4..e065862b20a 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -13,6 +13,7 @@ interface ExpressionResultFactory */ public function create( MutatingScope $scope, + MutatingScope $beforeScope, bool $hasYield, bool $isAlwaysTerminating, array $throwPoints, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ae9d28d8371..707011d1cf6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2799,11 +2799,12 @@ public function processExprNode( if ($expr instanceof List_) { // only in assign and foreach, processed elsewhere - return $this->expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + return $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); } return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], @@ -3170,7 +3171,7 @@ public function processArrowFunctionNode( $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return $this->expressionResultFactory->create($scope, false, $exprResult->isAlwaysTerminating(), $exprResult->getThrowPoints(), $exprResult->getImpurePoints()); + return $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); } /** @@ -3888,7 +3889,7 @@ public function processArgs( } // not storing this, it's scope after processing all args - return $this->expressionResultFactory->create($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } /** @@ -4040,7 +4041,7 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), false, ); } From 3495200ef0c8f3d6976a1316d4826d528b44e9ca Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 12:36:11 +0200 Subject: [PATCH 003/378] ExpressionResult - add Expr --- .../ExprHandler/ArrayDimFetchHandler.php | 6 +-- src/Analyser/ExprHandler/ArrayHandler.php | 1 + .../ExprHandler/ArrowFunctionHandler.php | 1 + src/Analyser/ExprHandler/AssignHandler.php | 12 +++--- src/Analyser/ExprHandler/AssignOpHandler.php | 4 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 3 +- .../ExprHandler/BitwiseNotHandler.php | 1 + .../ExprHandler/BooleanAndHandler.php | 1 + .../ExprHandler/BooleanNotHandler.php | 3 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 1 + src/Analyser/ExprHandler/CastHandler.php | 3 +- .../ExprHandler/CastStringHandler.php | 3 +- .../ExprHandler/ClassConstFetchHandler.php | 3 +- src/Analyser/ExprHandler/CloneHandler.php | 1 + src/Analyser/ExprHandler/ClosureHandler.php | 1 + src/Analyser/ExprHandler/CoalesceHandler.php | 3 +- .../ExprHandler/ConstFetchHandler.php | 3 +- src/Analyser/ExprHandler/EmptyHandler.php | 3 +- .../ExprHandler/ErrorSuppressHandler.php | 1 + src/Analyser/ExprHandler/EvalHandler.php | 1 + src/Analyser/ExprHandler/ExitHandler.php | 1 + src/Analyser/ExprHandler/FuncCallHandler.php | 3 +- .../Helper/ImplicitToStringCallHelper.php | 2 + src/Analyser/ExprHandler/IncludeHandler.php | 1 + .../ExprHandler/InstanceofHandler.php | 3 +- .../ExprHandler/InterpolatedStringHandler.php | 1 + src/Analyser/ExprHandler/IssetHandler.php | 3 +- src/Analyser/ExprHandler/MatchHandler.php | 1 + .../ExprHandler/MethodCallHandler.php | 6 +-- src/Analyser/ExprHandler/NewHandler.php | 1 + .../ExprHandler/NullsafeMethodCallHandler.php | 3 +- .../NullsafePropertyFetchHandler.php | 3 +- src/Analyser/ExprHandler/PipeHandler.php | 1 + src/Analyser/ExprHandler/PostDecHandler.php | 1 + src/Analyser/ExprHandler/PostIncHandler.php | 1 + src/Analyser/ExprHandler/PreDecHandler.php | 1 + src/Analyser/ExprHandler/PreIncHandler.php | 1 + src/Analyser/ExprHandler/PrintHandler.php | 1 + .../ExprHandler/PropertyFetchHandler.php | 3 +- src/Analyser/ExprHandler/ScalarHandler.php | 1 + .../ExprHandler/StaticCallHandler.php | 3 +- .../StaticPropertyFetchHandler.php | 3 +- src/Analyser/ExprHandler/TernaryHandler.php | 3 +- src/Analyser/ExprHandler/ThrowHandler.php | 1 + .../ExprHandler/UnaryMinusHandler.php | 1 + src/Analyser/ExprHandler/UnaryPlusHandler.php | 1 + src/Analyser/ExprHandler/VariableHandler.php | 1 + .../Virtual/AlwaysRememberedExprHandler.php | 3 +- .../Virtual/ExistingArrayDimFetchHandler.php | 1 + .../Virtual/FunctionCallableNodeHandler.php | 1 + .../Virtual/GetIterableKeyTypeExprHandler.php | 1 + .../GetIterableValueTypeExprHandler.php | 1 + .../Virtual/GetOffsetValueTypeExprHandler.php | 1 + .../InstantiationCallableNodeHandler.php | 1 + .../Virtual/MethodCallableNodeHandler.php | 1 + .../Virtual/NativeTypeExprHandler.php | 1 + .../OriginalPropertyTypeExprHandler.php | 1 + .../SetExistingOffsetValueTypeExprHandler.php | 1 + .../Virtual/SetOffsetValueTypeExprHandler.php | 1 + .../StaticMethodCallableNodeHandler.php | 1 + .../ExprHandler/Virtual/TypeExprHandler.php | 1 + .../Virtual/UnsetOffsetExprHandler.php | 1 + src/Analyser/ExprHandler/YieldFromHandler.php | 1 + src/Analyser/ExprHandler/YieldHandler.php | 1 + src/Analyser/ExpressionResult.php | 40 +++++++++++-------- src/Analyser/ExpressionResultFactory.php | 3 ++ src/Analyser/NodeScopeResolver.php | 15 ++++--- 67 files changed, 106 insertions(+), 77 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 59ea71f1e31..0a0198cf3c2 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -89,12 +89,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } @@ -119,12 +118,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 78eb2b5491d..a8e58b932a4 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -104,6 +104,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index b98fc9c85de..5390ee8e212 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $result->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 370c79db30c..99436212da9 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -342,7 +342,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $scope = $scope->exitExpressionAssign($expr->expr); } - return $this->expressionResultFactory->create($scope, $beforeScope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $expr->expr, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); }, true, ); @@ -387,12 +387,11 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } @@ -943,16 +942,17 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } + $getOffsetValueTypeExpr = new GetOffsetValueTypeExpr($assignedExpr, $dimExpr); $result = $this->processAssignVar( $nodeScopeResolver, $scope, $storage, $stmt, $arrayItem->value, - new GetOffsetValueTypeExpr($assignedExpr, $dimExpr), + $getOffsetValueTypeExpr, $nodeCallback, $context, - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -1044,7 +1044,7 @@ public function processAssignVar( } // stored where processAssignVar is called - return $this->expressionResultFactory->create($scope, $beforeScope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } private function createArrayDimFetchConditionalExpressionHolder( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 7bc92d78d3d..599d946f23c 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -88,6 +88,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), $originalScope, + $expr->expr, $exprResult->hasYield(), $isAlwaysTerminating, $exprResult->getThrowPoints(), @@ -120,12 +121,11 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 70d7841012a..c3d686ba134 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -107,12 +107,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index ae9ec208bd5..4b6b6667823 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index d96b8616595..5263207b8cb 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -276,6 +276,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $leftMergedWithRightScope, beforeScope: $scope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 08091ce1dc0..59cb5a986c1 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -46,12 +46,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 6099acd215d..a14cd3bd122 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -354,6 +354,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $leftMergedWithRightScope, beforeScope: $scope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index 99085f9b800..6877fdd5b3e 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -55,12 +55,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 32f31ec60a1..bb13a0e2817 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -60,12 +60,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 3283d8f4153..fe989f332c9 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -89,12 +89,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index ee1951e68d5..bc707347411 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index e0e742e812d..b107c5843c6 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 0beda07a2a6..8323d307c36 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -138,12 +138,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 23510dbbaba..17f429322e0 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -51,12 +51,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index d5f932b2b14..54e9c397fa1 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -98,12 +98,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 700d260261a..ca006ebcedb 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -42,6 +42,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 8d234e2ee68..93cf1adc508 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -53,6 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 0cba81da42c..7c1029c14e2 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -60,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: true, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 346b6eba7a4..cce0006e39d 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -591,12 +591,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 9303995b032..58ed6c39ac6 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -40,6 +40,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], @@ -73,6 +74,7 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 024b6ab5e84..e251cd84f44 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -55,6 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 077e965ffdc..b5288696912 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -69,12 +69,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index df25ce12114..cdf2bdba301 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -71,6 +71,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 001ee84f127..9fc6c135a1c 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -391,12 +391,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 6f794d430fe..66fd854eef4 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -507,6 +507,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 3d4b7617f95..4bc06266eda 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -204,12 +204,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $result = $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); $calledOnType = $originalScope->getType($expr->var); @@ -233,12 +232,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 56cc73980a1..d18070bf167 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -230,6 +230,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 05a94ad2668..215c1d6b497 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -121,12 +121,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index d9df195fd37..b5d253da2b0 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -100,12 +100,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index e6d010e2bf4..d3bbbc90413 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -92,6 +92,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $callResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $callResult->hasYield(), isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index c28a4a0175e..ecdf3bd84d8 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -50,6 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index f54fa7d4e0a..9a68af90336 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -50,6 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 344dde7a6ad..6569fde8c10 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -113,6 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 9e6d026f101..7d4be597076 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -114,6 +114,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeCallback, )->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index d495f8b63ca..cd6a90aee17 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -63,6 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 2bd9c8de439..9d4b69448ca 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -87,12 +87,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index ade6dc84023..9b4de986801 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index e60738c73e0..ed018fbd17f 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -292,12 +292,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index a5f51a3017c..a1b2f4d29b9 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -86,12 +86,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 497ba5e7f8b..5acd5310cb1 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -153,12 +153,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $finalScope, beforeScope: $scope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $finalScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $finalScope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 2cf4666b6bd..05bda29ec2d 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: true, throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index d97068c3acb..ea67e7dabc4 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index e2450452e31..6ec1abe38fc 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -46,6 +46,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $exprResult->getScope(), beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 3fdbdd0544a..6082dea55f2 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -98,6 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, $beforeScope, + $expr, $hasYield, $isAlwaysTerminating, $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 7a75e944ebc..99d6d9925e8 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -53,12 +53,11 @@ public function processExpr( return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $innerResult->hasYield(), isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($innerExpr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($innerExpr), ); } diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 02d15c413b8..411d2ee8d65 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index 8f3aaf827c2..e56509e627e 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -55,6 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php index 4d5f0e54a82..127ef939539 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php index 5b5b59ee994..56b7b9be00e 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php index 03f846910c4..ed0b5da45c5 100644 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index dddc6e216c9..937b6618d85 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -55,6 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 39b370ed581..28492541bce 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -58,6 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 0b7999acb50..852d00b14cd 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php index 9f36aee736c..2621d242cc7 100644 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php @@ -48,6 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 7c7f2847d29..11487e514f2 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index e604dece482..c85440094b8 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -45,6 +45,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 34e7dbf25b7..b12d7e120e5 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -64,6 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 26b05213a50..6ca636fe081 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 1246378b936..d414e648fd2 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -43,6 +43,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 089ce51b630..1aac8244af7 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -61,6 +61,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: true, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 76356bfbf7b..07abbc7e6ee 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -91,6 +91,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, + expr: $expr, hasYield: true, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index c75c3b3ac33..1e833cfcc0d 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,7 +2,9 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\Type\Type; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult @@ -27,6 +29,7 @@ final class ExpressionResult public function __construct( private MutatingScope $scope, private MutatingScope $beforeScope, + private Expr $expr, private bool $hasYield, private bool $isAlwaysTerminating, private array $throwPoints, @@ -44,11 +47,6 @@ public function getScope(): MutatingScope return $this->scope; } - public function getBeforeScope(): MutatingScope - { - return $this->beforeScope; - } - public function hasYield(): bool { return $this->hasYield; @@ -72,32 +70,30 @@ public function getImpurePoints(): array public function getTruthyScope(): MutatingScope { - if ($this->truthyScopeCallback === null) { - return $this->scope; - } - if ($this->truthyScope !== null) { return $this->truthyScope; } + if ($this->truthyScopeCallback === null) { + return $this->truthyScope = $this->scope->filterByTruthyValue($this->expr); + } + $callback = $this->truthyScopeCallback; - $this->truthyScope = $callback(); - return $this->truthyScope; + return $this->truthyScope = $callback(); } public function getFalseyScope(): MutatingScope { - if ($this->falseyScopeCallback === null) { - return $this->scope; - } - if ($this->falseyScope !== null) { return $this->falseyScope; } + if ($this->falseyScopeCallback === null) { + return $this->falseyScope = $this->scope->filterByFalseyValue($this->expr); + } + $callback = $this->falseyScopeCallback; - $this->falseyScope = $callback(); - return $this->falseyScope; + return $this->falseyScope = $callback(); } public function isAlwaysTerminating(): bool @@ -105,4 +101,14 @@ public function isAlwaysTerminating(): bool return $this->isAlwaysTerminating; } + public function getType(): Type + { + return $this->beforeScope->getType($this->expr); + } + + public function getNativeType(): Type + { + return $this->beforeScope->getNativeType($this->expr); + } + } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index e065862b20a..255d6258fba 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -2,6 +2,8 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; + interface ExpressionResultFactory { @@ -14,6 +16,7 @@ interface ExpressionResultFactory public function create( MutatingScope $scope, MutatingScope $beforeScope, + Expr $expr, bool $hasYield, bool $isAlwaysTerminating, array $throwPoints, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 707011d1cf6..83df7483652 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1324,9 +1324,9 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $stmt->type, $scope, $storage); } } elseif ($stmt instanceof If_) { - $conditionType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); - $ifAlwaysTrue = $conditionType->isTrue()->yes(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $conditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $ifAlwaysTrue = $conditionType->isTrue()->yes(); $exitPoints = []; $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -2799,18 +2799,17 @@ public function processExprNode( if ($expr instanceof List_) { // only in assign and foreach, processed elsewhere - return $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); } return $this->expressionResultFactory->create( $scope, beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } @@ -3171,7 +3170,7 @@ public function processArrowFunctionNode( $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); + return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); } /** @@ -3889,7 +3888,7 @@ public function processArgs( } // not storing this, it's scope after processing all args - return $this->expressionResultFactory->create($scope, $scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } /** @@ -4041,7 +4040,7 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), false, ); } From 5793b2edb40bb7f161f3eaaa719a5c68d92e5aa0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 20:16:10 +0200 Subject: [PATCH 004/378] Store ExpressionResult instead of before-Scope --- src/Analyser/ExprHandler/AssignHandler.php | 34 +++++++++++--- src/Analyser/ExprHandler/AssignOpHandler.php | 4 -- src/Analyser/ExpressionResult.php | 5 +++ src/Analyser/ExpressionResultStorage.php | 38 +++++++--------- ...equest.php => ExpressionResultRequest.php} | 5 +-- src/Analyser/Fiber/FiberNodeScopeResolver.php | 44 +++++++++++++------ src/Analyser/Fiber/FiberScope.php | 42 +++++++++++++----- src/Analyser/NodeScopeResolver.php | 42 +++++++++++++----- 8 files changed, 140 insertions(+), 74 deletions(-) rename src/Analyser/Fiber/{BeforeScopeForExprRequest.php => ExpressionResultRequest.php} (61%) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 99436212da9..9fb13bb0b47 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -330,7 +330,6 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto ); } - $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); @@ -413,6 +412,15 @@ public function processAssignVar( ): ExpressionResult { $beforeScope = $scope; + $nodeScopeResolver->storeExpressionResult($storage, $var, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $var, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + )); $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; @@ -420,7 +428,6 @@ public function processAssignVar( $isAlwaysTerminating = false; $isAssignOp = $assignedExpr instanceof Expr\AssignOp && !$enterExpressionAssign; if ($var instanceof Variable) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $result = $processExprCallback($scope); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); @@ -600,7 +607,15 @@ public function processAssignVar( if ($dimExpr === null) { $offsetTypes[] = [null, $dimFetch]; $offsetNativeTypes[] = [null, $dimFetch]; - $nodeScopeResolver->storeBeforeScope($storage, $dimFetch, $scope); + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + )); } else { $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; @@ -609,7 +624,15 @@ public function processAssignVar( if ($enterExpressionAssign) { $scope->enterExpressionAssign($dimExpr); } - $nodeScopeResolver->storeBeforeScope($storage, $dimFetch, $scope); + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + )); $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -731,7 +754,6 @@ public function processAssignVar( )->getThrowPoints()); } } elseif ($var instanceof PropertyFetch) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $objectResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, $context); $hasYield = $objectResult->hasYield(); $throwPoints = $objectResult->getThrowPoints(); @@ -844,7 +866,6 @@ public function processAssignVar( } } elseif ($var instanceof Expr\StaticPropertyFetch) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); if ($var->class instanceof Node\Name) { $propertyHolderType = $scope->resolveTypeByName($var->class); } else { @@ -910,7 +931,6 @@ public function processAssignVar( $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); } } elseif ($var instanceof List_) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $result = $processExprCallback($scope); $hasYield = $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 599d946f23c..fb25464e4b5 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -83,7 +83,6 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { - $nodeScopeResolver->storeBeforeScope($storage, $expr, $originalScope); $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), @@ -100,9 +99,6 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto }, $expr instanceof Expr\AssignOp\Coalesce, ); - if (!$expr instanceof Expr\AssignOp\Coalesce) { - $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); - } $scope = $assignResult->getScope(); $throwPoints = $assignResult->getThrowPoints(); $impurePoints = $assignResult->getImpurePoints(); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 1e833cfcc0d..7669a67a2c8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -47,6 +47,11 @@ public function getScope(): MutatingScope return $this->scope; } + public function getBeforeScope(): MutatingScope + { + return $this->beforeScope; + } + public function hasYield(): bool { return $this->hasYield; diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index 3349030e0c8..9fe2d2d14bd 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -5,48 +5,42 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; -use PHPStan\Analyser\Fiber\BeforeScopeForExprRequest; +use PHPStan\Analyser\Fiber\ExpressionResultRequest; use PHPStan\Analyser\Fiber\ParkFiberRequest; -use function spl_object_id; +use SplObjectStorage; final class ExpressionResultStorage { - /** - * Keeps every stored Expr alive so its spl_object_id() cannot be reused - * by another node while $scopesById still maps it. - * - * @var array - */ - private array $exprsById = []; + /** @var SplObjectStorage */ + private SplObjectStorage $exprResults; - /** @var array */ - private array $scopesById = []; - - /** @var array, request: BeforeScopeForExprRequest}> */ + /** @var array, request: ExpressionResultRequest}> */ public array $pendingFibers = []; - /** @var list> */ + /** @var list> */ public array $parkedFibers = []; + public function __construct() + { + $this->exprResults = new SplObjectStorage(); + } + public function duplicate(): self { $new = new self(); - $new->exprsById = $this->exprsById; - $new->scopesById = $this->scopesById; + $new->exprResults->addAll($this->exprResults); return $new; } - public function storeBeforeScope(Expr $expr, Scope $scope): void + public function storeExpressionResult(Expr $expr, ExpressionResult $expressionResult): void { - $id = spl_object_id($expr); - $this->exprsById[$id] = $expr; - $this->scopesById[$id] = $scope; + $this->exprResults[$expr] = $expressionResult; } - public function findBeforeScope(Expr $expr): ?Scope + public function findExpressionResult(Expr $expr): ?ExpressionResult { - return $this->scopesById[spl_object_id($expr)] ?? null; + return $this->exprResults[$expr] ?? null; } } diff --git a/src/Analyser/Fiber/BeforeScopeForExprRequest.php b/src/Analyser/Fiber/ExpressionResultRequest.php similarity index 61% rename from src/Analyser/Fiber/BeforeScopeForExprRequest.php rename to src/Analyser/Fiber/ExpressionResultRequest.php index 0fc6ecd35cd..4f3ecabdf97 100644 --- a/src/Analyser/Fiber/BeforeScopeForExprRequest.php +++ b/src/Analyser/Fiber/ExpressionResultRequest.php @@ -3,12 +3,11 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node\Expr; -use PHPStan\Analyser\MutatingScope; -final class BeforeScopeForExprRequest +final class ExpressionResultRequest { - public function __construct(public readonly Expr $expr, public readonly MutatingScope $scope) + public function __construct(public readonly Expr $expr, public readonly FiberScope $scope) { } diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index e8d160f6a1d..a0340a3781b 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -5,9 +5,12 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionContext; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; @@ -48,26 +51,26 @@ public function callNodeCallback( $this->runFiberForNodeCallback($storage, $fiber, $request); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { - $storage->storeBeforeScope($expr, $beforeScope); - $this->processPendingFibersForRequestedExpr($storage, $expr, $beforeScope); + $storage->storeExpressionResult($expr, $expressionResult); + $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); } /** - * @param Fiber $fiber + * @param Fiber $fiber */ private function runFiberForNodeCallback( ExpressionResultStorage $storage, Fiber $fiber, - BeforeScopeForExprRequest|ParkFiberRequest|null $request, + ExpressionResultRequest|ParkFiberRequest|null $request, ): void { while (!$fiber->isTerminated()) { - if ($request instanceof BeforeScopeForExprRequest) { - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { - $request = $fiber->resume($beforeScope); + if ($request instanceof ExpressionResultRequest) { + $expressionResult = $storage->findExpressionResult($request->expr); + if ($expressionResult !== null) { + $request = $fiber->resume($expressionResult); continue; } @@ -100,16 +103,29 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void foreach ($storage->pendingFibers as $key => $pending) { $request = $pending['request']; - $beforeScope = $storage->findBeforeScope($request->expr); + $expressionResult = $storage->findExpressionResult($request->expr); - if ($beforeScope !== null) { + if ($expressionResult !== null) { throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } unset($storage->pendingFibers[$key]); $fiber = $pending['fiber']; - $request = $fiber->resume($request->scope); + + // Process the expression with a duplicated storage so that the result + // computed from the asker's scope does not poison the real storage. + // The expression might still be processed naturally later (e.g. a loop + // condition asked about by a rule before the loop converges) and other + // fibers need to wait for that result instead of this one. + $request = $fiber->resume($this->processExprNode( + new Node\Stmt\Expression($request->expr), + $request->expr, + $request->scope->toMutatingScope(), + $storage->duplicate(), + new NoopNodeCallback(), + ExpressionContext::createTopLevel(), + )); $this->runFiberForNodeCallback($storage, $fiber, $request); // Break and restart the loop since the array may have been modified @@ -117,7 +133,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void } } - private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, Scope $result): void + private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { start: @@ -130,7 +146,7 @@ private function processPendingFibersForRequestedExpr(ExpressionResultStorage $s unset($storage->pendingFibers[$key]); $fiber = $pending['fiber']; - $request = $fiber->resume($result); + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); // Break and restart the loop since the array may have been modified diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index b02f322c358..698cf7fa1d0 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -4,6 +4,7 @@ use Fiber; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; @@ -11,6 +12,7 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; use function array_pop; +use function count; final class FiberScope extends MutatingScope { @@ -57,12 +59,20 @@ public function toMutatingScope(): MutatingScope /** @api */ public function getType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + return $expressionResult->getType(); + } + + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getType($node); } @@ -79,23 +89,31 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($expr, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($expr, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + return $expressionResult->getNativeType(); + } + + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getNativeType($expr); } public function getKeepVoidType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getKeepVoidType($node); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 83df7483652..cd0bcf3b662 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -353,7 +353,7 @@ public function processNodes( $this->processPendingFibers($expressionResultStorage); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { } @@ -2773,7 +2773,6 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { - $this->storeBeforeScope($storage, $expr, $scope); if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); @@ -2787,22 +2786,30 @@ public function processExprNode( throw new ShouldNotHappenException(); } - return $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $newExprResult = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $expressionResult = $this->expressionResultFactory->create( + $newExprResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $newExprResult->hasYield(), + isAlwaysTerminating: $newExprResult->isAlwaysTerminating(), + throwPoints: $newExprResult->getThrowPoints(), + impurePoints: $newExprResult->getImpurePoints(), + ); + $this->storeExpressionResult($storage, $expr, $expressionResult); + return $expressionResult; } $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { - return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); - } - - if ($expr instanceof List_) { - // only in assign and foreach, processed elsewhere - return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + $expressionResult = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); + $this->storeExpressionResult($storage, $expr, $expressionResult); + return $expressionResult; } - return $this->expressionResultFactory->create( + $expressionResult = $this->expressionResultFactory->create( $scope, beforeScope: $scope, expr: $expr, @@ -2811,6 +2818,9 @@ public function processExprNode( throwPoints: [], impurePoints: [], ); + $this->storeExpressionResult($storage, $expr, $expressionResult); + + return $expressionResult; } /** @@ -3676,7 +3686,15 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); } - $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $this->storeExpressionResult($storage, $arg->value, new ExpressionResult( + $closureResult->getScope(), + $scopeToPass, + $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + )); $uses = []; foreach ($arg->value->uses as $use) { @@ -3747,7 +3765,7 @@ public function processArgs( $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; } } - $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $this->storeExpressionResult($storage, $arg->value, $arrowFunctionResult); } else { $exprType = $scope->getType($arg->value); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; From 7c1259030ce78b307ceb4b6839ded9c51d4a4d7c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 11:23:57 +0200 Subject: [PATCH 005/378] Fill the missing gaps in expr processing --- src/Analyser/ExprHandler/AssignHandler.php | 6 ++++ src/Analyser/ExprHandler/PipeHandler.php | 19 ++++++++++++ src/Analyser/ExpressionResultStorage.php | 5 ++++ src/Analyser/Fiber/FiberNodeScopeResolver.php | 29 +++++++++++-------- src/Analyser/NodeScopeResolver.php | 29 +++++++++++++++++-- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 9fb13bb0b47..bc92f76bbc4 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -998,10 +998,16 @@ public function processAssignVar( $var = $var->getVar(); } + // the chain is usually a clone of AST nodes already processed elsewhere + // (see Unset_ handling) - process it with a noop callback so that + // results for its nodes are stored without invoking rules twice + $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $offsetTypes = []; $offsetNativeTypes = []; foreach (array_reverse($dimFetchStack) as $dimFetch) { $dimExpr = $dimFetch->getDim(); + $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index d3bbbc90413..a10009c2d56 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -69,24 +69,43 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex unset($rightAttributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $argAttributes = $expr->getAttribute(ReversePipeTransformerVisitor::ARG_ATTRIBUTES_NAME, []); + $isRightFirstClassCallable = false; if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { $callExpr = new FuncCall($expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); + $isRightFirstClassCallable = true; } elseif ($expr->right instanceof MethodCall && $expr->right->isFirstClassCallable()) { $callExpr = new MethodCall($expr->right->var, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); + $isRightFirstClassCallable = true; } elseif ($expr->right instanceof StaticCall && $expr->right->isFirstClassCallable()) { $callExpr = new StaticCall($expr->right->class, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); + $isRightFirstClassCallable = true; } else { $callExpr = new FuncCall($expr->right, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); } + if ($isRightFirstClassCallable) { + // the original first-class callable node is not processed through + // processExprNode - store its result so that node callbacks asking + // about its type can be resumed + $nodeScopeResolver->storeExpressionResult($storage, $expr->right, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr->right, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + )); + } + $callResult = $nodeScopeResolver->processExprNode($stmt, $callExpr, $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index 9fe2d2d14bd..daea8cf3c93 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -33,6 +33,11 @@ public function duplicate(): self return $new; } + public function mergeResults(self $other): void + { + $this->exprResults->addAll($other->exprResults); + } + public function storeExpressionResult(Expr $expr, ExpressionResult $expressionResult): void { $this->exprResults[$expr] = $expressionResult; diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index a0340a3781b..2beceacb7d0 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -113,19 +113,24 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void $fiber = $pending['fiber']; - // Process the expression with a duplicated storage so that the result + // Process the synthetic node with a duplicated storage so that the result // computed from the asker's scope does not poison the real storage. - // The expression might still be processed naturally later (e.g. a loop - // condition asked about by a rule before the loop converges) and other - // fibers need to wait for that result instead of this one. - $request = $fiber->resume($this->processExprNode( - new Node\Stmt\Expression($request->expr), - $request->expr, - $request->scope->toMutatingScope(), - $storage->duplicate(), - new NoopNodeCallback(), - ExpressionContext::createTopLevel(), - )); + // Real AST nodes contained in the synthetic node already have their + // results stored and are not processed again. + $this->returnStoredExpressionResults = true; + try { + $expressionResult = $this->processExprNode( + new Node\Stmt\Expression($request->expr), + $request->expr, + $request->scope->toMutatingScope(), + $storage->duplicate(), + new NoopNodeCallback(), + ExpressionContext::createTopLevel(), + ); + } finally { + $this->returnStoredExpressionResults = false; + } + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); // Break and restart the loop since the array may have been modified diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index cd0bcf3b662..5150b35273d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -206,6 +206,12 @@ class NodeScopeResolver /** @var array */ private array $calledMethodResults = []; + /** + * When processing a synthetic node on demand (for a Fiber request), real AST + * nodes contained in it were already processed and must not be processed again. + */ + protected bool $returnStoredExpressionResults = false; + /** * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) * @param array $earlyTerminatingFunctionCalls @@ -1438,10 +1444,15 @@ public function processStmtNode( $throwPoints = []; $impurePoints = []; - $traitStorage = $storage->duplicate(); - $traitStorage->pendingFibers = []; + // fresh storage - the same trait node objects are processed once per + // using class and fibers must not see results from a previous pass + $traitStorage = new ExpressionResultStorage(); $this->processTraitUse($stmt, $scope, $traitStorage, $nodeCallback); $this->processPendingFibers($traitStorage); + + // class-level node callbacks (like ClassMethodsNode) are invoked with + // the outer storage but ask about expressions inside the used trait + $storage->mergeResults($traitStorage); } elseif ($stmt instanceof Foreach_) { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); @@ -2773,6 +2784,13 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { + if ($this->returnStoredExpressionResults) { + $storedResult = $storage->findExpressionResult($expr); + if ($storedResult !== null) { + return $storedResult; + } + } + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); @@ -3686,7 +3704,7 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); } - $this->storeExpressionResult($storage, $arg->value, new ExpressionResult( + $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( $closureResult->getScope(), $scopeToPass, $arg->value, @@ -4757,6 +4775,11 @@ private function processNodesForTraitUse($node, ClassReflection $traitReflection throw new ShouldNotHappenException(); } $traitScope = $scope->enterTrait($traitReflection); + + // attribute args are not processed as part of the trait statements + // but rules like TraitAttributesRule ask about their types + $this->processAttributeGroups($node, $node->attrGroups, $traitScope, $storage, new NoopNodeCallback()); + $this->callNodeCallback($nodeCallback, new InTraitNode($node, $traitReflection, $scope->getClassReflection()), $traitScope, $storage); $this->processStmtNodesInternal($node, $stmts, $traitScope, $storage, $nodeCallback, StatementContext::createTopLevel()); return; From 1c0e999fdba2d30919657f701b759e1a2a67fa60 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 11:52:11 +0200 Subject: [PATCH 006/378] Divide ExprHandler into TypeResolvingExprHandler Not all ExprHandlers will be TypeResolvingExprHandler coming into the future. Instead of resolveType+specifyTypes, they will pass callbacks into ExpressionResult doing similar job. --- src/Analyser/ExprHandler.php | 16 ---------- .../ExprHandler/ArrayDimFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/ArrayHandler.php | 6 ++-- .../ExprHandler/ArrowFunctionHandler.php | 6 ++-- src/Analyser/ExprHandler/AssignHandler.php | 6 ++-- src/Analyser/ExprHandler/AssignOpHandler.php | 6 ++-- src/Analyser/ExprHandler/BinaryOpHandler.php | 6 ++-- .../ExprHandler/BitwiseNotHandler.php | 6 ++-- .../ExprHandler/BooleanAndHandler.php | 6 ++-- .../ExprHandler/BooleanNotHandler.php | 6 ++-- src/Analyser/ExprHandler/BooleanOrHandler.php | 6 ++-- src/Analyser/ExprHandler/CastHandler.php | 6 ++-- .../ExprHandler/CastStringHandler.php | 6 ++-- .../ExprHandler/ClassConstFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/CloneHandler.php | 6 ++-- src/Analyser/ExprHandler/ClosureHandler.php | 6 ++-- src/Analyser/ExprHandler/CoalesceHandler.php | 6 ++-- .../ExprHandler/ConstFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/EmptyHandler.php | 6 ++-- .../ExprHandler/ErrorSuppressHandler.php | 6 ++-- src/Analyser/ExprHandler/EvalHandler.php | 6 ++-- src/Analyser/ExprHandler/ExitHandler.php | 6 ++-- .../FirstClassCallableFuncCallHandler.php | 6 ++-- .../FirstClassCallableMethodCallHandler.php | 6 ++-- .../FirstClassCallableNewHandler.php | 6 ++-- .../FirstClassCallableStaticCallHandler.php | 6 ++-- src/Analyser/ExprHandler/FuncCallHandler.php | 6 ++-- src/Analyser/ExprHandler/IncludeHandler.php | 6 ++-- .../ExprHandler/InstanceofHandler.php | 6 ++-- .../ExprHandler/InterpolatedStringHandler.php | 6 ++-- src/Analyser/ExprHandler/IssetHandler.php | 6 ++-- src/Analyser/ExprHandler/MatchHandler.php | 6 ++-- .../ExprHandler/MethodCallHandler.php | 6 ++-- src/Analyser/ExprHandler/NewHandler.php | 6 ++-- .../ExprHandler/NullsafeMethodCallHandler.php | 6 ++-- .../NullsafePropertyFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/PipeHandler.php | 6 ++-- src/Analyser/ExprHandler/PostDecHandler.php | 6 ++-- src/Analyser/ExprHandler/PostIncHandler.php | 6 ++-- src/Analyser/ExprHandler/PreDecHandler.php | 6 ++-- src/Analyser/ExprHandler/PreIncHandler.php | 6 ++-- src/Analyser/ExprHandler/PrintHandler.php | 6 ++-- .../ExprHandler/PropertyFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/ScalarHandler.php | 6 ++-- .../ExprHandler/StaticCallHandler.php | 6 ++-- .../StaticPropertyFetchHandler.php | 6 ++-- src/Analyser/ExprHandler/TernaryHandler.php | 6 ++-- src/Analyser/ExprHandler/ThrowHandler.php | 6 ++-- .../ExprHandler/UnaryMinusHandler.php | 6 ++-- src/Analyser/ExprHandler/UnaryPlusHandler.php | 6 ++-- src/Analyser/ExprHandler/VariableHandler.php | 6 ++-- .../Virtual/AlwaysRememberedExprHandler.php | 6 ++-- .../Virtual/ExistingArrayDimFetchHandler.php | 6 ++-- .../Virtual/FunctionCallableNodeHandler.php | 6 ++-- .../Virtual/GetIterableKeyTypeExprHandler.php | 6 ++-- .../GetIterableValueTypeExprHandler.php | 6 ++-- .../Virtual/GetOffsetValueTypeExprHandler.php | 6 ++-- .../InstantiationCallableNodeHandler.php | 6 ++-- .../Virtual/MethodCallableNodeHandler.php | 6 ++-- .../Virtual/NativeTypeExprHandler.php | 6 ++-- .../OriginalPropertyTypeExprHandler.php | 6 ++-- .../SetExistingOffsetValueTypeExprHandler.php | 6 ++-- .../Virtual/SetOffsetValueTypeExprHandler.php | 6 ++-- .../StaticMethodCallableNodeHandler.php | 6 ++-- .../ExprHandler/Virtual/TypeExprHandler.php | 6 ++-- .../Virtual/UnsetOffsetExprHandler.php | 6 ++-- src/Analyser/ExprHandler/YieldFromHandler.php | 6 ++-- src/Analyser/ExprHandler/YieldHandler.php | 6 ++-- src/Analyser/MutatingScope.php | 2 +- src/Analyser/TypeResolvingExprHandler.php | 30 +++++++++++++++++++ src/Analyser/TypeSpecifier.php | 2 +- 71 files changed, 233 insertions(+), 219 deletions(-) create mode 100644 src/Analyser/TypeResolvingExprHandler.php diff --git a/src/Analyser/ExprHandler.php b/src/Analyser/ExprHandler.php index 98b8728ca40..7e93df1a6cf 100644 --- a/src/Analyser/ExprHandler.php +++ b/src/Analyser/ExprHandler.php @@ -5,7 +5,6 @@ use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; -use PHPStan\Type\Type; /** * @template T of Expr @@ -32,19 +31,4 @@ public function processExpr( ExpressionContext $context, ): ExpressionResult; - /** - * @param T $expr - */ - public function resolveType(MutatingScope $scope, Expr $expr): Type; - - /** - * @param T $expr - */ - public function specifyTypes( - TypeSpecifier $typeSpecifier, - Scope $scope, - Expr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes; - } diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 0a0198cf3c2..14dec18a003 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -13,13 +13,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,10 +30,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ArrayDimFetchHandler implements ExprHandler +final class ArrayDimFetchHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a8e58b932a4..6fe74abef78 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -12,11 +12,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,10 +30,10 @@ use function count; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ArrayHandler implements ExprHandler +final class ArrayHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 5390ee8e212..da01eb8e39f 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -9,22 +9,22 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ArrowFunctionHandler implements ExprHandler +final class ArrowFunctionHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index bc92f76bbc4..f06f15f5efd 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -28,7 +28,6 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -36,6 +35,7 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -85,10 +85,10 @@ use function is_string; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class AssignHandler implements ExprHandler +final class AssignHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index fb25464e4b5..2b7d0dd236e 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -13,13 +13,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -34,10 +34,10 @@ use function sprintf; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class AssignOpHandler implements ExprHandler +final class AssignOpHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index c3d686ba134..516f54780a0 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -16,7 +16,6 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; @@ -25,6 +24,7 @@ use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -54,10 +54,10 @@ use function strtolower; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class BinaryOpHandler implements ExprHandler +final class BinaryOpHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 4b6b6667823..5efa2fdef6d 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +21,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class BitwiseNotHandler implements ExprHandler +final class BitwiseNotHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 5263207b8cb..7ceb06af1bd 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -12,13 +12,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -34,10 +34,10 @@ use function is_string; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class BooleanAndHandler implements ExprHandler +final class BooleanAndHandler implements TypeResolvingExprHandler { private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 59cb5a986c1..fc4f263faee 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class BooleanNotHandler implements ExprHandler +final class BooleanNotHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index a14cd3bd122..fd32e84aa4b 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -10,13 +10,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -34,10 +34,10 @@ use function count; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class BooleanOrHandler implements ExprHandler +final class BooleanOrHandler implements TypeResolvingExprHandler { private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index 6877fdd5b3e..af928d21ed1 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -15,11 +15,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -28,10 +28,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class CastHandler implements ExprHandler +final class CastHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index bb13a0e2817..daa2c148be6 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -11,12 +11,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -25,10 +25,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class CastStringHandler implements ExprHandler +final class CastStringHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index fe989f332c9..c00160c7c50 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -10,11 +10,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ClassConstFetchHandler implements ExprHandler +final class ClassConstFetchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index bc707347411..caddb7d0889 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -9,12 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\Traverser\CloneTypeTraverser; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use PHPStan\Type\TypeTraverser; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class CloneHandler implements ExprHandler +final class CloneHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index b107c5843c6..65e801d6350 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -9,22 +9,22 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ClosureHandler implements ExprHandler +final class ClosureHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 8323d307c36..1a0407ce27c 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -9,12 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -27,10 +27,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class CoalesceHandler implements ExprHandler +final class CoalesceHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 17f429322e0..6f1518f163c 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -26,10 +26,10 @@ use function strtolower; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ConstFetchHandler implements ExprHandler +final class ConstFetchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 54e9c397fa1..1939315de1b 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -10,12 +10,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -25,10 +25,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class EmptyHandler implements ExprHandler +final class EmptyHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index ca006ebcedb..25bfa07bd36 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -9,21 +9,21 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ErrorSuppressHandler implements ExprHandler +final class ErrorSuppressHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 93cf1adc508..a381daf1c25 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -9,13 +9,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class EvalHandler implements ExprHandler +final class EvalHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 7c1029c14e2..c459f38dc11 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -9,12 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -23,10 +23,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ExitHandler implements ExprHandler +final class ExitHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php index 266996eaeb2..2fafbee4dbe 100644 --- a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php +++ b/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FirstClassCallableFuncCallHandler implements ExprHandler +final class FirstClassCallableFuncCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php index 1cafdd5b120..e487de41fb6 100644 --- a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php +++ b/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php @@ -10,11 +10,11 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FirstClassCallableMethodCallHandler implements ExprHandler +final class FirstClassCallableMethodCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php b/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php index e158a8cc7b8..9f5e05c198d 100644 --- a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php +++ b/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -23,10 +23,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FirstClassCallableNewHandler implements ExprHandler +final class FirstClassCallableNewHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php index 4d3519cf944..4a5c3cd0645 100644 --- a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php +++ b/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FirstClassCallableStaticCallHandler implements ExprHandler +final class FirstClassCallableStaticCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index cce0006e39d..ad56dca9dc1 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -18,7 +18,6 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -28,6 +27,7 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -83,10 +83,10 @@ use function str_starts_with; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FuncCallHandler implements ExprHandler +final class FuncCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index e251cd84f44..7c8de06849c 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -9,13 +9,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -25,10 +25,10 @@ use function in_array; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class IncludeHandler implements ExprHandler +final class IncludeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index b5288696912..885a418f846 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -10,11 +10,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -33,10 +33,10 @@ use function strtolower; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class InstanceofHandler implements ExprHandler +final class InstanceofHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index cdf2bdba301..75547f573bb 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,12 +10,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -25,10 +25,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class InterpolatedStringHandler implements ExprHandler +final class InterpolatedStringHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 9fc6c135a1c..7d379b16556 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -17,13 +17,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -52,10 +52,10 @@ use function is_string; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class IssetHandler implements ExprHandler +final class IssetHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 66fd854eef4..88e80239efa 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -18,12 +18,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -49,10 +49,10 @@ use const SORT_NUMERIC; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class MatchHandler implements ExprHandler +final class MatchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 4bc06266eda..071e65cdbdf 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -13,7 +13,6 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -23,6 +22,7 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -49,10 +49,10 @@ use function strtolower; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class MethodCallHandler implements ExprHandler +final class MethodCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index d18070bf167..5298f6710db 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -13,7 +13,6 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -25,6 +24,7 @@ use PHPStan\Analyser\ThrowPoint; use PHPStan\Analyser\Traverser\ConstructorClassTemplateTraverser; use PHPStan\Analyser\Traverser\GenericTypeTemplateTraverser; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -66,10 +66,10 @@ use function sprintf; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class NewHandler implements ExprHandler +final class NewHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 215c1d6b497..b5db844ed26 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -14,12 +14,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,10 +30,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class NullsafeMethodCallHandler implements ExprHandler +final class NullsafeMethodCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index b5d253da2b0..017a57b723d 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -14,12 +14,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,10 +30,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class NullsafePropertyFetchHandler implements ExprHandler +final class NullsafePropertyFetchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index a10009c2d56..45a5e23322d 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -13,11 +13,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -27,10 +27,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PipeHandler implements ExprHandler +final class PipeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index ecdf3bd84d8..0cff682498c 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -10,21 +10,21 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PostDecHandler implements ExprHandler +final class PostDecHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 9a68af90336..a04b0cfea75 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -10,21 +10,21 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PostIncHandler implements ExprHandler +final class PostIncHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 6569fde8c10..221376fd209 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -35,10 +35,10 @@ use function str_decrement; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PreDecHandler implements ExprHandler +final class PreDecHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 7d4be597076..73fd74c7dc5 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -36,10 +36,10 @@ use function str_increment; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PreIncHandler implements ExprHandler +final class PreIncHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index cd6a90aee17..2b21db38ec4 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,13 +9,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,10 +24,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PrintHandler implements ExprHandler +final class PrintHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 9d4b69448ca..43c0dab3d19 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -11,13 +11,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -32,10 +32,10 @@ use function count; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class PropertyFetchHandler implements ExprHandler +final class PropertyFetchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 9b4de986801..dba4a0669da 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -10,11 +10,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -23,10 +23,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ScalarHandler implements ExprHandler +final class ScalarHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index ed018fbd17f..b71127b4cc1 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -16,7 +16,6 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -27,6 +26,7 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -57,10 +57,10 @@ use function strtolower; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class StaticCallHandler implements ExprHandler +final class StaticCallHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index a1b2f4d29b9..b8fc583e450 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -13,13 +13,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -33,10 +33,10 @@ use function count; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class StaticPropertyFetchHandler implements ExprHandler +final class StaticPropertyFetchHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 5acd5310cb1..9aa0142bc24 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -11,12 +11,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -26,10 +26,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class TernaryHandler implements ExprHandler +final class TernaryHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 05bda29ec2d..8231e9b6726 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -9,12 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -23,10 +23,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ThrowHandler implements ExprHandler +final class ThrowHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index ea67e7dabc4..ad84baeee02 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +21,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class UnaryMinusHandler implements ExprHandler +final class UnaryMinusHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 6ec1abe38fc..c3f38936c05 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +21,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class UnaryPlusHandler implements ExprHandler +final class UnaryPlusHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 6082dea55f2..70ce8439856 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -11,12 +11,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -29,10 +29,10 @@ use function is_string; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class VariableHandler implements ExprHandler +final class VariableHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 99d6d9925e8..d4700f6e048 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class AlwaysRememberedExprHandler implements ExprHandler +final class AlwaysRememberedExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 411d2ee8d65..61b7884270d 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class ExistingArrayDimFetchHandler implements ExprHandler +final class ExistingArrayDimFetchHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index e56509e627e..b27515f40fd 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +21,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class FunctionCallableNodeHandler implements ExprHandler +final class FunctionCallableNodeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php index 127ef939539..6f32dd6c6ec 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class GetIterableKeyTypeExprHandler implements ExprHandler +final class GetIterableKeyTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php index 56b7b9be00e..950d1025266 100644 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class GetIterableValueTypeExprHandler implements ExprHandler +final class GetIterableValueTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php index ed0b5da45c5..ad70185384f 100644 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class GetOffsetValueTypeExprHandler implements ExprHandler +final class GetOffsetValueTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 937b6618d85..304aedb86ad 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -21,10 +21,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class InstantiationCallableNodeHandler implements ExprHandler +final class InstantiationCallableNodeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 28492541bce..25ca81bbcd0 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class MethodCallableNodeHandler implements ExprHandler +final class MethodCallableNodeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 852d00b14cd..08715ad31cc 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class NativeTypeExprHandler implements ExprHandler +final class NativeTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php index 2621d242cc7..0e346c921c5 100644 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class OriginalPropertyTypeExprHandler implements ExprHandler +final class OriginalPropertyTypeExprHandler implements TypeResolvingExprHandler { public function __construct( diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 11487e514f2..56a154af6f8 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use PHPStan\Type\UnionType; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class SetExistingOffsetValueTypeExprHandler implements ExprHandler +final class SetExistingOffsetValueTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index c85440094b8..08659b35101 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use PHPStan\Type\UnionType; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class SetOffsetValueTypeExprHandler implements ExprHandler +final class SetOffsetValueTypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index b12d7e120e5..8f9570ee5ec 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,10 +22,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class StaticMethodCallableNodeHandler implements ExprHandler +final class StaticMethodCallableNodeHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 6ca636fe081..32208c6a569 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class TypeExprHandler implements ExprHandler +final class TypeExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index d414e648fd2..9f5e4368b27 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -8,11 +8,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -20,10 +20,10 @@ use PHPStan\Type\Type; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class UnsetOffsetExprHandler implements ExprHandler +final class UnsetOffsetExprHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 1aac8244af7..ca086700866 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -10,13 +10,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -26,10 +26,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class YieldFromHandler implements ExprHandler +final class YieldFromHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 07abbc7e6ee..dd8a5478fc0 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -10,13 +10,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -26,10 +26,10 @@ use function array_merge; /** - * @implements ExprHandler + * @implements TypeResolvingExprHandler */ #[AutowiredService] -final class YieldHandler implements ExprHandler +final class YieldHandler implements TypeResolvingExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1f843048b25..33a4550f543 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1070,7 +1070,7 @@ private function resolveType(string $exprString, Expr $node): Type } $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); - if ($exprHandler !== null) { + if ($exprHandler instanceof TypeResolvingExprHandler) { return $exprHandler->resolveType($this, $node); } diff --git a/src/Analyser/TypeResolvingExprHandler.php b/src/Analyser/TypeResolvingExprHandler.php new file mode 100644 index 00000000000..030a2dfb8a3 --- /dev/null +++ b/src/Analyser/TypeResolvingExprHandler.php @@ -0,0 +1,30 @@ + + */ +interface TypeResolvingExprHandler extends ExprHandler +{ + + /** + * @param T $expr + */ + public function resolveType(MutatingScope $scope, Expr $expr): Type; + + /** + * @param T $expr + */ + public function specifyTypes( + TypeSpecifier $typeSpecifier, + Scope $scope, + Expr $expr, + TypeSpecifierContext $context, + ): SpecifiedTypes; + +} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3bc9039336b..7bd6b57d1a6 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -90,7 +90,7 @@ public function specifyTypesInCondition( } $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); - if ($exprHandler !== null) { + if ($exprHandler instanceof TypeResolvingExprHandler) { return $exprHandler->specifyTypes($this, $scope, $expr, $context); } From 814d98c95cbffeab01fa5b69b35efdcd4c78f666 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 12:21:16 +0200 Subject: [PATCH 007/378] ScalarHandler stops implementing TypeResolvingExprHandler --- src/Analyser/ExprHandler/ScalarHandler.php | 22 +++--------- src/Analyser/ExpressionResult.php | 40 ++++++++++++++++++++-- src/Analyser/ExpressionResultFactory.php | 3 ++ src/Analyser/MutatingScope.php | 2 +- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index dba4a0669da..690ec8b8dd6 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -10,23 +10,19 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; -use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ScalarHandler implements TypeResolvingExprHandler +final class ScalarHandler implements ExprHandler { public function __construct( @@ -43,6 +39,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + // TODO $typeSpecifier->specifyDefaultTypes($scope, $expr, $context) OR noop return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -51,17 +48,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: fn (Scope $scope) => $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($scope)), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($scope)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7669a67a2c8..4bc44318c57 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -4,12 +4,17 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Type\Type; +use PHPStan\Type\TypeUtils; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult { + /** @var (callable(MutatingScope, Expr): Type)|null */ + private $typeCallback; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -20,13 +25,19 @@ final class ExpressionResult private ?MutatingScope $falseyScope = null; + private ?Type $cachedType = null; + + private ?Type $cachedNativeType = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints + * @param (callable(MutatingScope, Expr): Type)|null $typeCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback */ public function __construct( + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, private MutatingScope $scope, private MutatingScope $beforeScope, private Expr $expr, @@ -36,10 +47,12 @@ public function __construct( private array $impurePoints, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, + ?callable $typeCallback = null, ) { $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; + $this->typeCallback = $typeCallback; } public function getScope(): MutatingScope @@ -108,12 +121,35 @@ public function isAlwaysTerminating(): bool public function getType(): Type { - return $this->beforeScope->getType($this->expr); + if ($this->cachedType !== null) { + return $this->cachedType; + } + + foreach ($this->expressionTypeResolverExtensionRegistryProvider->getRegistry()->getExtensions() as $extension) { + $type = $extension->getType($this->expr, $this->beforeScope); + if ($type !== null) { + return $this->cachedType = $type; + } + } + + if ($this->typeCallback !== null) { + return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope, $this->expr)); + } + + return $this->cachedType = $this->beforeScope->getType($this->expr); } public function getNativeType(): Type { - return $this->beforeScope->getNativeType($this->expr); + if ($this->cachedNativeType !== null) { + return $this->cachedNativeType; + } + + if ($this->typeCallback !== null) { + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); + } + + return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); } } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 255d6258fba..d7c5955f4c1 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PHPStan\Type\Type; interface ExpressionResultFactory { @@ -12,6 +13,7 @@ interface ExpressionResultFactory * @param ImpurePoint[] $impurePoints * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback + * @param (callable(MutatingScope, Expr): Type)|null $typeCallback */ public function create( MutatingScope $scope, @@ -23,6 +25,7 @@ public function create( array $impurePoints, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, + ?callable $typeCallback = null, ): ExpressionResult; } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 33a4550f543..5190450aec7 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1276,7 +1276,7 @@ public function getKeepVoidType(Expr $node): Type return $this->getType($clonedNode); } - public function doNotTreatPhpDocTypesAsCertain(): Scope + public function doNotTreatPhpDocTypesAsCertain(): self { return $this->promoteNativeTypes(); } From 90a0f82ef88c97ce184eefeb3f1d5142b6cdb8de Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 18:46:29 +0200 Subject: [PATCH 008/378] ExpressionResultStorageStack - answer type questions from ExpressionResults Old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers' resolveType) keep working for nodes whose handler no longer implements TypeResolvingExprHandler: every scope shares the ExpressionResultStorageStack created by its internal scope factory, NodeScopeResolver pushes the storage of the analysis in progress through MutatingScope::pushExpressionResultStorage() (always popped in finally), and MutatingScope resolves such nodes from the stored result - or by processing a synthetic node on demand. Also adds MutatingScope::applySpecifiedTypes (filterBySpecifiedTypes without Scope::getType()) and the specifyTypesCallback slot on ExpressionResult consulted by getTruthyScope()/getFalseyScope(). The cycle collector is disabled in bin/phpstan - scopes deliberately never reference a storage directly, only the stack. Popping severs the stack->storage edge when an analysis ends, so retained scopes do not pin the whole result graph. Co-Authored-By: Claude Fable 5 --- phpstan-baseline.neon | 20 +- src/Analyser/DirectInternalScopeFactory.php | 30 +-- src/Analyser/ExpressionResult.php | 36 +++ src/Analyser/ExpressionResultFactory.php | 2 + src/Analyser/ExpressionResultStorage.php | 10 +- src/Analyser/ExpressionResultStorageStack.php | 52 ++++ src/Analyser/Fiber/FiberNodeScopeResolver.php | 27 +- src/Analyser/LazyInternalScopeFactory.php | 9 +- src/Analyser/MutatingScope.php | 234 ++++++++++++++++-- src/Analyser/NodeScopeResolver.php | 74 +++++- 10 files changed, 421 insertions(+), 73 deletions(-) create mode 100644 src/Analyser/ExpressionResultStorageStack.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 246d46dee0d..dabac702b5c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 1 + count: 5 path: src/Analyser/MutatingScope.php - @@ -114,12 +114,6 @@ parameters: count: 1 path: src/Analyser/RuleErrorTransformer.php - - - rawMessage: Casting to string something that's already string. - identifier: cast.useless - count: 2 - path: src/Analyser/ScopeOps.php - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' identifier: phpstanApi.instanceofType @@ -150,6 +144,12 @@ parameters: count: 1 path: src/Collectors/Registry.php + - + rawMessage: Anonymous function has an unused use $container. + identifier: closure.unusedUse + count: 1 + path: src/Command/CommandHelper.php + - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass @@ -765,6 +765,12 @@ parameters: count: 1 path: src/Testing/LevelsTestCase.php + - + rawMessage: Anonymous function has an unused use $container. + identifier: closure.unusedUse + count: 1 + path: src/Testing/PHPStanTestCase.php + - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 533d37c5f92..02afcab90e5 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -19,6 +19,8 @@ final class DirectInternalScopeFactory implements InternalScopeFactory { + private ExpressionResultStorageStack $expressionResultStorageStack; + /** * @param int|array{min: int, max: int}|null $configPhpVersion * @param callable(Node $node, Scope $scope): void|null $nodeCallback @@ -38,8 +40,10 @@ public function __construct( private $nodeCallback, private ConstantResolver $constantResolver, private bool $fiber = false, + ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { + $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } public function create( @@ -77,6 +81,7 @@ public function create( $this->propertyReflectionFinder, $this->parser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersion, $this->attributeReflectionFactory, @@ -102,25 +107,15 @@ public function create( public function toFiberFactory(): InternalScopeFactory { - return new self( - $this->container, - $this->reflectionProvider, - $this->initializerExprTypeResolver, - $this->expressionTypeResolverExtensionRegistryProvider, - $this->exprPrinter, - $this->typeSpecifier, - $this->propertyReflectionFinder, - $this->parser, - $this->phpVersion, - $this->attributeReflectionFactory, - $this->configPhpVersion, - $this->nodeCallback, - $this->constantResolver, - true, - ); + return $this->withFlavor(true); } public function toMutatingFactory(): InternalScopeFactory + { + return $this->withFlavor(false); + } + + private function withFlavor(bool $fiber): self { return new self( $this->container, @@ -136,7 +131,8 @@ public function toMutatingFactory(): InternalScopeFactory $this->configPhpVersion, $this->nodeCallback, $this->constantResolver, - false, + $fiber, + $this->expressionResultStorageStack, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 4bc44318c57..9128ea6d2e0 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -15,6 +15,9 @@ final class ExpressionResult /** @var (callable(MutatingScope, Expr): Type)|null */ private $typeCallback; + /** @var (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ + private $specifyTypesCallback; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -33,6 +36,7 @@ final class ExpressionResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param (callable(MutatingScope, Expr): Type)|null $typeCallback + * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback */ @@ -48,11 +52,13 @@ public function __construct( ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, + ?callable $specifyTypesCallback = null, ) { $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; $this->typeCallback = $typeCallback; + $this->specifyTypesCallback = $specifyTypesCallback; } public function getScope(): MutatingScope @@ -93,6 +99,12 @@ public function getTruthyScope(): MutatingScope } if ($this->truthyScopeCallback === null) { + if ($this->specifyTypesCallback !== null) { + return $this->truthyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), + ); + } + return $this->truthyScope = $this->scope->filterByTruthyValue($this->expr); } @@ -107,6 +119,12 @@ public function getFalseyScope(): MutatingScope } if ($this->falseyScopeCallback === null) { + if ($this->specifyTypesCallback !== null) { + return $this->falseyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), + ); + } + return $this->falseyScope = $this->scope->filterByFalseyValue($this->expr); } @@ -152,4 +170,22 @@ public function getNativeType(): Type return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); } + public function hasTypeCallback(): bool + { + return $this->typeCallback !== null; + } + + /** + * Re-evaluates the expression type on a different scope (e.g. a narrowed one). + * Unlike getType(), the result is not cached. + */ + public function getTypeForScope(MutatingScope $scope): Type + { + if ($this->typeCallback !== null) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); + } + + return $scope->getType($this->expr); + } + } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index d7c5955f4c1..43926896f5f 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -14,6 +14,7 @@ interface ExpressionResultFactory * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback * @param (callable(MutatingScope, Expr): Type)|null $typeCallback + * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback */ public function create( MutatingScope $scope, @@ -26,6 +27,7 @@ public function create( ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, + ?callable $specifyTypesCallback = null, ): ExpressionResult; } diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index daea8cf3c93..b04bfb507e2 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -15,6 +15,12 @@ final class ExpressionResultStorage /** @var SplObjectStorage */ private SplObjectStorage $exprResults; + /** + * Read-only fallback - writes never reach it. Makes duplicate() O(1) + * instead of copying all stored results. + */ + private ?self $fallback = null; + /** @var array, request: ExpressionResultRequest}> */ public array $pendingFibers = []; @@ -29,7 +35,7 @@ public function __construct() public function duplicate(): self { $new = new self(); - $new->exprResults->addAll($this->exprResults); + $new->fallback = $this; return $new; } @@ -45,7 +51,7 @@ public function storeExpressionResult(Expr $expr, ExpressionResult $expressionRe public function findExpressionResult(Expr $expr): ?ExpressionResult { - return $this->exprResults[$expr] ?? null; + return $this->exprResults[$expr] ?? $this->fallback?->findExpressionResult($expr); } } diff --git a/src/Analyser/ExpressionResultStorageStack.php b/src/Analyser/ExpressionResultStorageStack.php new file mode 100644 index 00000000000..3bf644f7965 --- /dev/null +++ b/src/Analyser/ExpressionResultStorageStack.php @@ -0,0 +1,52 @@ + results -> scopes -> storage) + * that never gets collected because the cycle collector is disabled + * in bin/phpstan. + * + * NodeScopeResolver pushes a storage for the duration of an analysis (file, + * statement list, trait pass, on-demand expression) through + * MutatingScope::pushExpressionResultStorage() and must always pop it + * in a finally block. Old-world type questions about expressions whose + * handler no longer implements TypeResolvingExprHandler are answered from + * the current storage (see MutatingScope::resolveTypeOfNewWorldHandlerNode()). + * A scope used outside any running analysis simply misses here and resolves + * on demand with a throwaway storage. + */ +final class ExpressionResultStorageStack +{ + + /** @var list */ + private array $stack = []; + + public function push(ExpressionResultStorage $storage): void + { + $this->stack[] = $storage; + } + + public function pop(): void + { + array_pop($this->stack); + } + + public function getCurrent(): ?ExpressionResultStorage + { + if (count($this->stack) === 0) { + return null; + } + + return $this->stack[count($this->stack) - 1]; + } + +} diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 2beceacb7d0..3d74df8e1d6 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -5,7 +5,6 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; -use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; @@ -32,6 +31,12 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + if ($nodeCallback instanceof NoopNodeCallback) { + // fibers exist solely to let node callbacks ask about types, + // a noop callback does not need one + return; + } + if (Fiber::getCurrent() !== null) { $nodeCallback($node, $scope->toFiberScope()); return; @@ -115,21 +120,11 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void // Process the synthetic node with a duplicated storage so that the result // computed from the asker's scope does not poison the real storage. - // Real AST nodes contained in the synthetic node already have their - // results stored and are not processed again. - $this->returnStoredExpressionResults = true; - try { - $expressionResult = $this->processExprNode( - new Node\Stmt\Expression($request->expr), - $request->expr, - $request->scope->toMutatingScope(), - $storage->duplicate(), - new NoopNodeCallback(), - ExpressionContext::createTopLevel(), - ); - } finally { - $this->returnStoredExpressionResults = false; - } + $expressionResult = $this->processExprOnDemand( + $request->expr, + $request->scope->toMutatingScope(), + $storage->duplicate(), + ); $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index 30bad59a9a4..b554cc1a3dd 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -41,6 +41,8 @@ final class LazyInternalScopeFactory implements InternalScopeFactory private ?ConstantResolver $constantResolver = null; + private ExpressionResultStorageStack $expressionResultStorageStack; + private ?PhpVersion $phpVersionType = null; private ?AttributeReflectionFactory $attributeReflectionFactory = null; @@ -52,10 +54,12 @@ public function __construct( private Container $container, private $nodeCallback, private bool $fiber = false, + ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { $this->phpVersion = $this->container->getParameter('phpVersion'); $this->currentSimpleVersionParser = $this->container->getService('currentPhpVersionSimpleParser'); + $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } public function create( @@ -105,6 +109,7 @@ public function create( $this->propertyReflectionFinder, $this->currentSimpleVersionParser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersionType, $this->attributeReflectionFactory, @@ -130,12 +135,12 @@ public function create( public function toFiberFactory(): InternalScopeFactory { - return new self($this->container, $this->nodeCallback, true); + return new self($this->container, $this->nodeCallback, true, $this->expressionResultStorageStack); } public function toMutatingFactory(): InternalScopeFactory { - return new self($this->container, $this->nodeCallback, false); + return new self($this->container, $this->nodeCallback, false, $this->expressionResultStorageStack); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 5190450aec7..8947cc26959 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -181,6 +181,7 @@ public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private Parser $parser, private ConstantResolver $constantResolver, + private ExpressionResultStorageStack $expressionResultStorageStack, protected ScopeContext $context, private PhpVersion $phpVersion, private AttributeReflectionFactory $attributeReflectionFactory, @@ -1070,13 +1071,68 @@ private function resolveType(string $exprString, Expr $node): Type } $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); - if ($exprHandler instanceof TypeResolvingExprHandler) { - return $exprHandler->resolveType($this, $node); + if ($exprHandler !== null) { + if ($exprHandler instanceof TypeResolvingExprHandler) { + return $exprHandler->resolveType($this, $node); + } + + return $this->resolveTypeOfNewWorldHandlerNode($node); } return new MixedType(); } + /** + * The handler of the node no longer implements TypeResolvingExprHandler. + * The answer comes from the ExpressionResult stored during the analysis + * currently in progress, or from processing the node on demand (synthetic + * nodes, or no analysis in progress at all). + * + * The scope deliberately does not reference the storage - that would create + * a reference cycle that never gets collected (see ExpressionResultStorageStack). + */ + private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $result = $storage->findExpressionResult($node); + if ($result !== null) { + if (!$result->hasTypeCallback()) { + throw new ShouldNotHappenException(sprintf( + 'ExprHandler for %s does not implement TypeResolvingExprHandler but its ExpressionResult is missing a typeCallback.', + get_class($node), + )); + } + + return $result->getTypeForScope($this); + } + } + + // a synthetic node, or no analysis in progress + $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $this, + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); + + return $onDemandResult->getTypeForScope($this); + } + + /** + * Makes the storage answer type questions asked on this scope (and every + * scope sharing its ExpressionResultStorageStack) for the duration of an + * analysis. The caller must pop in a finally block. + */ + public function pushExpressionResultStorage(ExpressionResultStorage $storage): void + { + $this->expressionResultStorageStack->push($storage); + } + + public function popExpressionResultStorage(): void + { + $this->expressionResultStorageStack->pop(); + } + /** * @param callable(Type): ?bool $typeCallback */ @@ -3292,17 +3348,168 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } - [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); - return $this->applyFilteredConditions($scope, $conditions, $specifiedTypes); + /** @var static */ + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); } /** - * @param array $conditions - * @return static + * New-world counterpart of filterBySpecifiedTypes. + * + * The types inside SpecifiedTypes were already computed from ExpressionResults + * by the specifyTypesCallback of an ExprHandler. This method must never call + * Scope::getType() - it only combines the given types with already-tracked + * expression type holders. + */ + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self + { + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + + $scope = $this; + $specifiedExpressions = []; + foreach ($typeSpecifications as $typeSpecification) { + $expr = $typeSpecification['expr']; + $type = $typeSpecification['type']; + $exprString = $typeSpecification['exprString']; + + if ($expr instanceof IssetExpr) { + $issetExpr = $expr; + $expr = $issetExpr->getExpr(); + + if ($typeSpecification['sure']) { + $scope = $scope->setExpressionCertainty( + $expr, + TrinaryLogic::createMaybe(), + ); + } else { + $scope = $scope->unsetExpression($expr); + } + + continue; + } + + $trackedType = null; + $trackedNativeType = null; + if (array_key_exists($exprString, $scope->expressionTypes)) { + $trackedType = $scope->expressionTypes[$exprString]->getType(); + } + if (array_key_exists($exprString, $scope->nativeExpressionTypes)) { + $trackedNativeType = $scope->nativeExpressionTypes[$exprString]->getType(); + } + + if ($typeSpecification['sure']) { + if ($specifiedTypes->shouldOverwrite()) { + $scope = $scope->assignExpression($expr, $type, $type); + } else { + $newType = $trackedType !== null ? TypeCombinator::intersect($type, $trackedType) : $type; + $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($type, $trackedNativeType) : $type; + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + } else { + if ($type instanceof NeverType || $trackedType instanceof NeverType) { + continue; + } + $newType = $trackedType !== null ? TypeCombinator::remove($trackedType, $type) : null; + if ($newType === null) { + // the expression is not tracked - there is nothing to subtract from + continue; + } + $newNativeType = $trackedNativeType !== null ? TypeCombinator::remove($trackedNativeType, $type) : $newType; + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + + $holderType = array_key_exists($exprString, $scope->expressionTypes) + ? $scope->expressionTypes[$exprString]->getType() + : $type; + $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); + } + + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); + + /** @var static */ + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); + } + + /** + * Matches already-registered conditional expressions against the just-specified + * expression type holders and applies the matching consequences. + * + * Mutates and returns $this - only to be called on an intermediate scope + * that is about to be rebuilt through the scope factory. + * + * @param array $specifiedExpressions */ - private function applyFilteredConditions(self $scope, array $conditions, SpecifiedTypes $specifiedTypes): self + private function processConditionalExpressionsAfterSpecifying(array $specifiedExpressions): self { + $scope = $this; + [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + foreach ($conditions as $conditionalExprString => $expressions) { $certainty = TrinaryLogic::lazyExtremeIdentity($expressions, static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getCertainty()); if ($certainty->no()) { @@ -3325,18 +3532,7 @@ private function applyFilteredConditions(self $scope, array $conditions, Specifi } } - /** @var static */ - return ScopeOps::scopeWith( - $scope, - $scope->expressionTypes, - $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), - $scope->currentlyAssignedExpressions, - $scope->currentlyAllowedUndefinedExpressions, - $scope->inFunctionCallsStack, - $scope->inFirstLevelStatement, - $scope->afterExtractCall, - ); + return $scope; } /** diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 5150b35273d..fe5d8bad82d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -282,6 +282,25 @@ public function processNodes( ): void { $expressionResultStorage = new ExpressionResultStorage(); + $scope->pushExpressionResultStorage($expressionResultStorage); + try { + $this->processNodesWithStorage($nodes, $scope, $expressionResultStorage, $nodeCallback); + } finally { + $scope->popExpressionResultStorage(); + } + } + + /** + * @param Node[] $nodes + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processNodesWithStorage( + array $nodes, + MutatingScope $scope, + ExpressionResultStorage $expressionResultStorage, + callable $nodeCallback, + ): void + { $alreadyTerminated = false; $exitPoints = []; @@ -497,14 +516,19 @@ public function processStmtNodes( ): StatementResult { $storage = new ExpressionResultStorage(); - return $this->processStmtNodesInternal( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - )->toPublic(); + $scope->pushExpressionResultStorage($storage); + try { + return $this->processStmtNodesInternal( + $parentNode, + $stmts, + $scope, + $storage, + $nodeCallback, + $context, + )->toPublic(); + } finally { + $scope->popExpressionResultStorage(); + } } /** @@ -1447,8 +1471,13 @@ public function processStmtNode( // fresh storage - the same trait node objects are processed once per // using class and fibers must not see results from a previous pass $traitStorage = new ExpressionResultStorage(); - $this->processTraitUse($stmt, $scope, $traitStorage, $nodeCallback); - $this->processPendingFibers($traitStorage); + $scope->pushExpressionResultStorage($traitStorage); + try { + $this->processTraitUse($stmt, $scope, $traitStorage, $nodeCallback); + $this->processPendingFibers($traitStorage); + } finally { + $scope->popExpressionResultStorage(); + } // class-level node callbacks (like ClassMethodsNode) are invoked with // the outer storage but ask about expressions inside the used trait @@ -2772,6 +2801,31 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return null; } + /** + * Processes an expression outside the normal AST traversal - e.g. a synthetic + * node a rule or extension asks about. Real AST nodes contained in it return + * their already-stored results instead of being processed again. New results + * are stored into the given storage - pass a duplicate to keep them isolated. + */ + public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult + { + $this->returnStoredExpressionResults = true; + $scope->pushExpressionResultStorage($storage); + try { + return $this->processExprNode( + new Node\Stmt\Expression($expr), + $expr, + $scope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createTopLevel(), + ); + } finally { + $scope->popExpressionResultStorage(); + $this->returnStoredExpressionResults = false; + } + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ From cdc41e309296f60f8b70a5f75ed700d4f60e52ef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 18:46:29 +0200 Subject: [PATCH 009/378] Migrate ArrayHandler - per-item types from ExpressionResults Each item type is captured at its own evaluation point in the sequence, so [$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c] infers array{1, 2, 1, 3, 1, 2} - the old world resolves all items on a single scope and cannot do this. Until the item handlers (BinaryOp, inc/dec, Assign) migrate themselves, the items resolve as their own results' before-scope evaluation instead of cascading getTypeForScope(). Narrowing stays on the fallback path - it is identical to the removed specifyDefaultTypes body. Co-Authored-By: Claude Fable 5 --- src/Analyser/ExprHandler/ArrayHandler.php | 80 ++++++++++--------- .../PHPStan/Analyser/nsrt/assign-in-array.php | 23 ++++++ 2 files changed, 67 insertions(+), 36 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/assign-in-array.php diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 6fe74abef78..501fd5074de 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -12,13 +12,9 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; @@ -26,14 +22,16 @@ use PHPStan\Type\CallableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_key_exists; use function array_merge; use function count; +use function spl_object_id; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ArrayHandler implements TypeResolvingExprHandler +final class ArrayHandler implements ExprHandler { public function __construct( @@ -48,34 +46,11 @@ public function supports(Expr $expr): bool return $expr instanceof Array_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - - if ( - count($expr->items) === 2 - && isset($expr->items[0], $expr->items[1]) - && $type->isCallable()->maybe() - ) { - $isCallableCall = new FuncCall( - new FullyQualified('is_callable'), - [new Arg($expr)], - ); - if ( - $scope->hasExpressionType($isCallableCall)->yes() - && $scope->getType($isCallableCall)->isTrue()->yes() - ) { - $type = TypeCombinator::intersect($type, new CallableType()); - } - } - - return $type; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; $itemNodes = []; + $itemResults = []; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -85,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage); if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->key)] = $keyResult; $hasYield = $hasYield || $keyResult->hasYield(); $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); @@ -93,6 +69,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->value)] = $valueResult; $hasYield = $hasYield || $valueResult->hasYield(); $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); @@ -109,12 +86,43 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (MutatingScope $s) use ($expr, $itemResults): Type { + // each item type was captured at its own evaluation point in the + // sequence - resolving all items on any single scope (the old world) + // cannot handle items with side effects like [$b = 1, $b + 1, $b++] + $type = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemResults, $s): Type { + $id = spl_object_id($inner); + if (array_key_exists($id, $itemResults)) { + return $s->nativeTypesPromoted + ? $itemResults[$id]->getNativeType() + : $itemResults[$id]->getType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // getArrayType only asks about item keys and values - guarded + // legacy bridge just in case + return $s->getType($inner); + }); + + if ( + count($expr->items) === 2 + && isset($expr->items[0], $expr->items[1]) + && $type->isCallable()->maybe() + ) { + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), + [new Arg($expr)], + ); + if ( + $s->hasExpressionType($isCallableCall)->yes() + && $s->getType($isCallableCall)->isTrue()->yes() + ) { + $type = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; + }, + ); } } diff --git a/tests/PHPStan/Analyser/nsrt/assign-in-array.php b/tests/PHPStan/Analyser/nsrt/assign-in-array.php new file mode 100644 index 00000000000..955df512571 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/assign-in-array.php @@ -0,0 +1,23 @@ + Date: Fri, 12 Jun 2026 18:48:20 +0200 Subject: [PATCH 010/378] Throw on unbalanced ExpressionResultStorageStack pop An unbalanced push/pop is the one way the ambient storage design can still be misused - fail immediately instead of silently answering later type questions from the wrong storage. Co-Authored-By: Claude Fable 5 --- src/Analyser/ExpressionResultStorageStack.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Analyser/ExpressionResultStorageStack.php b/src/Analyser/ExpressionResultStorageStack.php index 3bf644f7965..9d0e37f4e71 100644 --- a/src/Analyser/ExpressionResultStorageStack.php +++ b/src/Analyser/ExpressionResultStorageStack.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PHPStan\ShouldNotHappenException; use function array_pop; use function count; @@ -37,6 +38,10 @@ public function push(ExpressionResultStorage $storage): void public function pop(): void { + if (count($this->stack) === 0) { + throw new ShouldNotHappenException('Unbalanced ExpressionResultStorageStack pop.'); + } + array_pop($this->stack); } From a3ea27db38555b64382840d228b51696a906252e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 19:19:11 +0200 Subject: [PATCH 011/378] Fix PHP 7.4 compat --- src/Analyser/ExpressionResultStorage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index b04bfb507e2..10c14749296 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -51,7 +51,7 @@ public function storeExpressionResult(Expr $expr, ExpressionResult $expressionRe public function findExpressionResult(Expr $expr): ?ExpressionResult { - return $this->exprResults[$expr] ?? $this->fallback?->findExpressionResult($expr); + return $this->exprResults[$expr] ?? ($this->fallback !== null ? $this->fallback->findExpressionResult($expr) : null); } } From 47e4dfe2454a9445ac3c51e57ebb278936dd5f31 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 19:35:07 +0200 Subject: [PATCH 012/378] Migrate VariableHandler and InstanceofHandler - narrowing from ExpressionResults Both handlers stop implementing TypeResolvingExprHandler and wire typeCallback + specifyTypesCallback, so their truthy/falsey scopes flow through MutatingScope::applySpecifiedTypes - the first real exercise of the new-world narrowing machinery. The old-world TypeSpecifier dispatcher answers nodes of converted handlers from the stored ExpressionResult (MutatingScope::specifyTypesOfNewWorldHandlerNode), processes synthetic nodes on demand (the 'foo' === $a::class rewrite builds synthetic Instanceof_ nodes), and falls back to specifyDefaultTypes when the result carries no callback - which is exactly what such handlers used to implement. Exercising the machinery surfaced two gaps in applySpecifiedTypes: - Expressions not tracked in the scope lost their sureNot narrowing ($var->name instanceof Identifier ? ... : ... stopped narrowing the else branch). The current type to intersect with or subtract from is now priced from the stored ExpressionResult (getCurrentTypesOfSpecifiedExpr) instead of Scope::getType(). - Only Yes-certainty holders hold the current type of an expression. A Maybe-certainty holder holds the when-defined type (falsy after an or-merge in nsrt/bug-pr-339.php), which the certainty-aware Scope::getType() never returned - narrowing against it produced NeverType and mis-fired conditional expression holders. AssignHandler's placeholder result for an assignment target now carries VariableHandler::createTypeCallback - every stored result for a converted handler's node type must answer type questions itself. Co-Authored-By: Claude Fable 5 --- src/Analyser/ExprHandler/AssignHandler.php | 3 + .../ExprHandler/InstanceofHandler.php | 193 +++++++++--------- src/Analyser/ExprHandler/VariableHandler.php | 92 +++++---- src/Analyser/ExpressionResult.php | 14 ++ src/Analyser/MutatingScope.php | 83 +++++++- src/Analyser/TypeSpecifier.php | 13 +- 6 files changed, 261 insertions(+), 137 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f06f15f5efd..ff54d8ec3c5 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -420,6 +420,9 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + // VariableHandler no longer implements TypeResolvingExprHandler - + // type questions about the target node are answered from this result + typeCallback: $var instanceof Variable ? VariableHandler::createTypeCallback($var) : null, )); $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 885a418f846..ff8bda6c552 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -10,11 +10,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -33,13 +32,16 @@ use function strtolower; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class InstanceofHandler implements TypeResolvingExprHandler +final class InstanceofHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + ) { } @@ -57,6 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $isAlwaysTerminating = $exprResult->isAlwaysTerminating(); $scope = $exprResult->getScope(); + $classResult = null; if (!$expr->class instanceof Name) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -74,104 +77,106 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $expressionType = $scope->getType($expr->expr); - if ( - $scope->isInTrait() - && TypeUtils::findThisType($expressionType) !== null - ) { - return new BooleanType(); - } - if ($expressionType instanceof NeverType) { - return new ConstantBooleanType(false); - } - - $uncertainty = false; - - if ($expr->class instanceof Name) { - $unresolvedClassName = $expr->class->toString(); - if ( - strtolower($unresolvedClassName) === 'static' - && $scope->isInClass() - ) { - $classType = new StaticType($scope->getClassReflection()); - } else { - $className = $scope->resolveName($expr->class); - $classType = new ObjectType($className); - } - } else { - $result = $scope->getType($expr->class)->toObjectTypeForInstanceofCheck(); - $classType = $result->type; - $uncertainty = $result->uncertainty; - } + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $classResult): Type { + $expressionType = $exprResult->getTypeForScope($s); + if ( + $s->isInTrait() + && TypeUtils::findThisType($expressionType) !== null + ) { + return new BooleanType(); + } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } - if ($classType->isSuperTypeOf(new MixedType())->yes()) { - return new BooleanType(); - } + $uncertainty = false; + + if ($expr->class instanceof Name) { + $unresolvedClassName = $expr->class->toString(); + if ( + strtolower($unresolvedClassName) === 'static' + && $s->isInClass() + ) { + $classType = new StaticType($s->getClassReflection()); + } else { + $className = $s->resolveName($expr->class); + $classType = new ObjectType($className); + } + } else { + $classNameType = $classResult !== null + ? $classResult->getTypeForScope($s) + : $s->getType($expr->class); + $result = $classNameType->toObjectTypeForInstanceofCheck(); + $classType = $result->type; + $uncertainty = $result->uncertainty; + } - $isSuperType = $classType->isSuperTypeOf($expressionType); + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } - if ($isSuperType->no()) { - return new ConstantBooleanType(false); - } elseif ($isSuperType->yes() && !$uncertainty) { - return new ConstantBooleanType(true); - } + $isSuperType = $classType->isSuperTypeOf($expressionType); - return new BooleanType(); - } + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - $exprNode = $expr->expr; - if ($expr->class instanceof Name) { - $className = (string) $expr->class; - $lowercasedClassName = strtolower($className); - if ($lowercasedClassName === 'self' && $scope->isInClass()) { - $type = new ObjectType($scope->getClassReflection()->getName()); - } elseif ($lowercasedClassName === 'static' && $scope->isInClass()) { - $type = new StaticType($scope->getClassReflection()); - } elseif ($lowercasedClassName === 'parent') { - if ( - $scope->isInClass() - && $scope->getClassReflection()->getParentClass() !== null - ) { - $type = new ObjectType($scope->getClassReflection()->getParentClass()->getName()); - } else { - $type = new NonexistentParentClassType(); + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $classResult): SpecifiedTypes { + $exprNode = $expr->expr; + if ($expr->class instanceof Name) { + $className = (string) $expr->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $s->isInClass()) { + $type = new ObjectType($s->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $s->isInClass()) { + $type = new StaticType($s->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $s->isInClass() + && $s->getClassReflection()->getParentClass() !== null + ) { + $type = new ObjectType($s->getClassReflection()->getParentClass()->getName()); + } else { + $type = new NonexistentParentClassType(); + } + } else { + $type = new ObjectType($className); + } + return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); } - } else { - $type = new ObjectType($className); - } - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); - } - $result = $scope->getType($expr->class)->toObjectTypeForInstanceofCheck(); - $type = $result->type; - $uncertainty = $result->uncertainty; - - if (!$type->isSuperTypeOf(new MixedType())->yes()) { - if ($context->true()) { - $type = TypeCombinator::intersect( - $type, - new ObjectWithoutClassType(), - ); - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); - } elseif ($context->false() && !$uncertainty) { - $exprType = $scope->getType($expr->expr); - if (!$type->isSuperTypeOf($exprType)->yes()) { - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + $classNameType = $classResult !== null + ? $classResult->getTypeForScope($s) + : $s->getType($expr->class); + $result = $classNameType->toObjectTypeForInstanceofCheck(); + $type = $result->type; + $uncertainty = $result->uncertainty; + + if (!$type->isSuperTypeOf(new MixedType())->yes()) { + if ($context->true()) { + $type = TypeCombinator::intersect( + $type, + new ObjectWithoutClassType(), + ); + return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); + } elseif ($context->false() && !$uncertainty) { + $exprType = $exprResult->getTypeForScope($s); + if (!$type->isSuperTypeOf($exprType)->yes()) { + return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); + } + } + } + if ($context->true()) { + return $this->typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $context, $s)->setRootExpr($exprNode); } - } - } - if ($context->true()) { - return $typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $context, $scope)->setRootExpr($exprNode); - } - return (new SpecifiedTypes([], []))->setRootExpr($expr); + return (new SpecifiedTypes([], []))->setRootExpr($expr); + }, + ); } } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 70ce8439856..1fc9923355f 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\Variable; @@ -11,12 +12,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -29,13 +30,16 @@ use function is_string; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class VariableHandler implements TypeResolvingExprHandler +final class VariableHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + ) { } @@ -44,36 +48,49 @@ public function supports(Expr $expr): bool return $expr instanceof Variable; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * Evaluates the variable as a read on the asking scope. Also used by + * AssignHandler for the placeholder result it stores for an assignment + * target - every stored result for a Variable node must carry a + * typeCallback now that this handler no longer implements + * TypeResolvingExprHandler. + * + * @return Closure(MutatingScope): Type + */ + public static function createTypeCallback(Variable $expr, ?ExpressionResult $nameResult = null): Closure { - if (is_string($expr->name)) { - if ($scope->hasVariableType($expr->name)->no()) { - return new ErrorType(); + return static function (MutatingScope $s) use ($expr, $nameResult): Type { + if (is_string($expr->name)) { + if ($s->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } + + return $s->getVariableType($expr->name); } - return $scope->getVariableType($expr->name); - } + $nameType = $nameResult !== null + ? $nameResult->getTypeForScope($s) + : $s->getType($expr->name); + if (count($nameType->getConstantStrings()) > 0) { + $types = []; + foreach ($nameType->getConstantStrings() as $constantString) { + $variableScope = $s + ->filterByTruthyValue( + new Identical($expr->name, new String_($constantString->getValue())), + ); + if ($variableScope->hasVariableType($constantString->getValue())->no()) { + $types[] = new ErrorType(); + continue; + } - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - $types = []; - foreach ($nameType->getConstantStrings() as $constantString) { - $variableScope = $scope - ->filterByTruthyValue( - new Identical($expr->name, new String_($constantString->getValue())), - ); - if ($variableScope->hasVariableType($constantString->getValue())->no()) { - $types[] = new ErrorType(); - continue; + $types[] = $variableScope->getVariableType($constantString->getValue()); } - $types[] = $variableScope->getVariableType($constantString->getValue()); + return TypeCombinator::union(...$types); } - return TypeCombinator::union(...$types); - } - - return new MixedType(); + return new MixedType(); + }; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -83,6 +100,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $nameResult = null; if (is_string($expr->name)) { if (in_array($expr->name, Scope::SUPERGLOBAL_VARIABLES, true)) { $impurePoints[] = new ImpurePoint($scope, $expr, 'superglobal', 'access to superglobal variable', true); @@ -95,22 +113,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } + return $this->expressionResultFactory->create( $scope, - $beforeScope, - $expr, - $hasYield, - $isAlwaysTerminating, - $throwPoints, - $impurePoints, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: self::createTypeCallback($expr, $nameResult), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->typeSpecifier->specifyDefaultTypes($s, $expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 9128ea6d2e0..411e1edeefc 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -175,6 +175,20 @@ public function hasTypeCallback(): bool return $this->typeCallback !== null; } + /** + * Re-evaluates the narrowing on a different scope (e.g. the one an old-world + * caller holds). Returns null when the handler wired no specifyTypesCallback - + * the caller falls back to default truthy/falsey narrowing. + */ + public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): ?SpecifiedTypes + { + if ($this->specifyTypesCallback === null) { + return null; + } + + return ($this->specifyTypesCallback)($scope, $context); + } + /** * Re-evaluates the expression type on a different scope (e.g. a narrowed one). * Unlike getType(), the result is not cached. diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8947cc26959..3b6bd775f95 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1118,6 +1118,64 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type return $onDemandResult->getTypeForScope($this); } + /** + * Prices the current (phpdoc, native) type pair of an expression that + * applySpecifiedTypes() needs to intersect with or subtract from but that + * is not tracked in the scope. Old-world filterBySpecifiedTypes() asked + * Scope::getType() here; pricing from the stored ExpressionResult answers + * through the typeCallback for converted handlers and keeps the legacy + * resolution as a bridge for the rest. Returns null for nodes the analysis + * in progress never processed (synthetic ones). + * + * @return array{Type, Type}|null + */ + private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage === null) { + return null; + } + + $result = $storage->findExpressionResult($expr); + if ($result === null) { + return null; + } + + return [ + $result->getTypeForScope($this), + $result->getTypeForScope($this->promoteNativeTypes()), + ]; + } + + /** + * Narrowing counterpart of resolveTypeOfNewWorldHandlerNode() - the old-world + * TypeSpecifier dispatcher asks here for nodes whose handler no longer + * implements TypeResolvingExprHandler. Returns null when the ExpressionResult + * carries no specifyTypesCallback - the dispatcher falls back to default + * truthy/falsey narrowing, which is what such handlers used to implement. + * + * @internal + */ + public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): ?SpecifiedTypes + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $result = $storage->findExpressionResult($node); + if ($result !== null) { + return $result->getSpecifiedTypesForScope($this, $context); + } + } + + // a synthetic node, or no analysis in progress + $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $this, + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); + + return $onDemandResult->getSpecifiedTypesForScope($this, $context); + } + /** * Makes the storage answer type questions asked on this scope (and every * scope sharing its ExpressionResultStorageStack) for the duration of an @@ -3437,14 +3495,35 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + // only Yes-certainty holders hold the current type of the expression - + // a Maybe-certainty holder holds the when-defined type (e.g. after + // merging a branch where the expression was never assigned), which + // the certainty-aware Scope::getType() of the old world never returned $trackedType = null; $trackedNativeType = null; - if (array_key_exists($exprString, $scope->expressionTypes)) { + if ( + array_key_exists($exprString, $scope->expressionTypes) + && $scope->expressionTypes[$exprString]->getCertainty()->yes() + ) { $trackedType = $scope->expressionTypes[$exprString]->getType(); } - if (array_key_exists($exprString, $scope->nativeExpressionTypes)) { + if ( + array_key_exists($exprString, $scope->nativeExpressionTypes) + && $scope->nativeExpressionTypes[$exprString]->getCertainty()->yes() + ) { $trackedNativeType = $scope->nativeExpressionTypes[$exprString]->getType(); } + if ($trackedType === null) { + $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr); + if ($currentTypes !== null) { + if ($scope->isComplexUnionType($currentTypes[0])) { + continue; + } + + $trackedType = $currentTypes[0]; + $trackedNativeType ??= $currentTypes[1]; + } + } if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7bd6b57d1a6..236afa02310 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -90,8 +90,17 @@ public function specifyTypesInCondition( } $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); - if ($exprHandler instanceof TypeResolvingExprHandler) { - return $exprHandler->specifyTypes($this, $scope, $expr, $context); + if ($exprHandler !== null) { + if ($exprHandler instanceof TypeResolvingExprHandler) { + return $exprHandler->specifyTypes($this, $scope, $expr, $context); + } + + if ($scope instanceof MutatingScope) { + $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } } return $this->specifyDefaultTypes($scope, $expr, $context); From e8b4e179bcf467020c8197ba61071b8183d285d3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 19:39:49 +0200 Subject: [PATCH 013/378] Add regression tests for evaluation-point array item types Closes https://github.com/phpstan/phpstan/issues/13944 Closes https://github.com/phpstan/phpstan/issues/12207 Closes https://github.com/phpstan/phpstan/issues/7155 Co-Authored-By: Claude Fable 5 --- tests/PHPStan/Analyser/nsrt/bug-12207.php | 31 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13944.php | 48 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-7155.php | 16 ++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12207.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13944.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7155.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-12207.php b/tests/PHPStan/Analyser/nsrt/bug-12207.php new file mode 100644 index 00000000000..63990d759c4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12207.php @@ -0,0 +1,31 @@ +}> + */ + public function bar(): Generator + { + yield 'foo' => [ + $a = 'string', + ['string' => $a], + ]; + } + + public function baz(): void + { + $value = [ + $a = 'string', + ['string' => $a], + ]; + assertType("array{'string', array{string: 'string'}}", $value); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13944.php b/tests/PHPStan/Analyser/nsrt/bug-13944.php new file mode 100644 index 00000000000..ed1aeaf31b0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13944.php @@ -0,0 +1,48 @@ +, + * "when@stage"?: array, + * } $config + */ +function config(array $config): void +{ +} + +config([ + 'when@dev' => $does_not_work = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], + ], + 'when@stage' => $does_not_work, +]); + +assertType("array{'when@dev': array{controllers: array{resource: 'routing.controllers'}}, 'when@stage': array{controllers: array{resource: 'routing.controllers'}}}", [ + 'when@dev' => $does_not_work, + 'when@stage' => $does_not_work, +]); + +assertType("array{'when@dev': array{controllers: array{resource: 'routing.controllers'}}, 'when@stage': array{controllers: array{resource: 'routing.controllers'}}}", [ + 'when@dev' => $defined_inside = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], + ], + 'when@stage' => $defined_inside, +]); + +$does_work = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], +]; +config([ + 'when@dev' => $does_work, + 'when@stage' => $does_work, +]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-7155.php b/tests/PHPStan/Analyser/nsrt/bug-7155.php new file mode 100644 index 00000000000..13fc56e4830 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7155.php @@ -0,0 +1,16 @@ + Date: Fri, 12 Jun 2026 19:49:13 +0200 Subject: [PATCH 014/378] Add regression test for certainty of undefined variables in loops Closes https://github.com/phpstan/phpstan/issues/2032 Co-Authored-By: Claude Fable 5 --- .../Variables/DefinedVariableRuleTest.php | 18 ++++++++++++++++++ .../PHPStan/Rules/Variables/data/bug-2032.php | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-2032.php diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index ca236634015..7b9fe148b19 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1701,4 +1701,22 @@ public function testBug10090(): void ]); } + public function testBug2032(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-2032.php'], [ + [ + 'Undefined variable: $undefined', + 6, + ], + [ + 'Undefined variable: $undefined', + 15, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-2032.php b/tests/PHPStan/Rules/Variables/data/bug-2032.php new file mode 100644 index 00000000000..65e9d1c5f62 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-2032.php @@ -0,0 +1,17 @@ + Date: Fri, 12 Jun 2026 20:01:15 +0200 Subject: [PATCH 015/378] Store expressions even without FNSR --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 2 +- src/Analyser/MutatingScope.php | 10 ++++++++++ src/Analyser/NodeScopeResolver.php | 3 +++ .../Rules/Variables/DefinedVariableRuleTest.php | 4 ++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 3d74df8e1d6..e49935f0023 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -58,7 +58,7 @@ public function callNodeCallback( public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { - $storage->storeExpressionResult($expr, $expressionResult); + parent::storeExpressionResult($storage, $expr, $expressionResult); $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3b6bd775f95..4d86f93b401 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3394,6 +3394,11 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->no()) { + // testing a certainly-undefined variable cannot make it defined + continue; + } + if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); @@ -3495,6 +3500,11 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->no()) { + // testing a certainly-undefined variable cannot make it defined + continue; + } + // only Yes-certainty holders hold the current type of the expression - // a Maybe-certainty holder holds the when-defined type (e.g. after // merging a branch where the expression was never assigned), which diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index fe5d8bad82d..c7893644241 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -380,6 +380,9 @@ private function processNodesWithStorage( public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { + // converted handlers (no TypeResolvingExprHandler) are answered from + // stored results in both worlds - storing must not depend on fibers + $storage->storeExpressionResult($expr, $expressionResult); } protected function processPendingFibers(ExpressionResultStorage $storage): void diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 7b9fe148b19..397dabed083 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1712,6 +1712,10 @@ public function testBug2032(): void 'Undefined variable: $undefined', 6, ], + [ + 'Undefined variable: $undefined', + 9, + ], [ 'Undefined variable: $undefined', 15, From e6ad4740898f0f32941b3171412f0b13a6d24493 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 20:07:57 +0200 Subject: [PATCH 016/378] Only sureNot specifications skip certainly-undefined variables A sure specification (e.g. is_string($a)) can only hold for a defined variable, so it still makes the variable defined inside the branch - one error at the test site, no cascade. Removing a type from a certainly-undefined variable proves nothing about its definedness. Co-Authored-By: Claude Fable 5 --- src/Analyser/MutatingScope.php | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4d86f93b401..c1651a712f0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3394,8 +3394,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->no()) { - // testing a certainly-undefined variable cannot make it defined + if ( + !$typeSpecification['sure'] + && $expr instanceof Variable && is_string($expr->name) + && $scope->hasVariableType($expr->name)->no() + ) { + // removing type from a certainly-undefined variable cannot make + // it defined; a sure specification (e.g. is_string($a)) still can - + // the condition can only hold for a defined variable continue; } @@ -3500,8 +3506,14 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } - if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->no()) { - // testing a certainly-undefined variable cannot make it defined + if ( + !$typeSpecification['sure'] + && $expr instanceof Variable && is_string($expr->name) + && $scope->hasVariableType($expr->name)->no() + ) { + // removing type from a certainly-undefined variable cannot make + // it defined; a sure specification (e.g. is_string($a)) still can - + // the condition can only hold for a defined variable continue; } From 0fcaa0cef92dfa8759c2413ba1cf32558e3c5c04 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 21:49:02 +0200 Subject: [PATCH 017/378] This is better --- src/Analyser/MutatingScope.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c1651a712f0..f9436dfeaca 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1142,8 +1142,8 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array } return [ - $result->getTypeForScope($this), - $result->getTypeForScope($this->promoteNativeTypes()), + $result->getType(), + $result->getNativeType(), ]; } From 459b5e7cb044cdec9c3159e6919fba594f06ab13 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 12 Jun 2026 23:00:24 +0200 Subject: [PATCH 018/378] ExpressionResult::createTypesCallback - the inside-out TypeSpecifier::create() How a type constraint on a node translates into narrowing entries is the producing handler's knowledge, declared on its ExpressionResult - never re-derived by unwrapping the AST elsewhere. DefaultNarrowingHelper (recreated from the first rewrite attempt) carries the default boolean-context narrowing (one sureNot entry, no type ask, no nullsafe chain-walking) and createSubjectTypes(): ask the subject result's createTypesCallback, fall back to a single sure/sureNot entry for the subject node. No purity gates, no structural unwrapping. AssignHandler fans a type constraint out to the assigned variable and the assigned expression - nested assignments compose through the assigned expression's own result, which is what will delete unwrapAssign. CoalesceHandler delegates to its left side when the type rules the right side in or out, so ($e ?? null) instanceof Foo narrows $e. AssignHandler also wires specifyTypesCallback: the assigned variable narrows by the boolean outcome, plus the $arr[$key] inference after $key = array_key_first/array_key_last/array_search/array_find_key. The null-context inferences stay in the old-world specifyTypes() - result-based asks are always truthy or falsey. specifyTypesCallbacks no longer touch TypeSpecifier: VariableHandler uses DefaultNarrowingHelper, InstanceofHandler narrows its subject through createSubjectTypes(). TypeSpecifierTest's assign-in-instanceof expectations hold unchanged - the new channel reproduces create()'s emission exactly. Co-Authored-By: Claude Fable 5 --- src/Analyser/ExprHandler/AssignHandler.php | 148 +++++++++++++++++- src/Analyser/ExprHandler/CoalesceHandler.php | 18 +++ .../Helper/DefaultNarrowingHelper.php | 91 +++++++++++ .../ExprHandler/InstanceofHandler.php | 12 +- src/Analyser/ExprHandler/VariableHandler.php | 6 +- src/Analyser/ExpressionResult.php | 25 +++ src/Analyser/ExpressionResultFactory.php | 2 + 7 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index ff54d8ec3c5..7feac501519 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -28,6 +28,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -97,6 +98,7 @@ public function __construct( private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -292,6 +294,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; + $assignedExprResult = null; $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -301,7 +304,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->expr, $nodeCallback, $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$assignedExprResult): ExpressionResult { $beforeScope = $scope; $impurePoints = []; if ($expr instanceof AssignRef) { @@ -331,6 +334,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $assignedExprResult = $result; $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -391,9 +395,151 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($expr) : null, + createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); } + /** + * A type constraint on an assignment constrains the assigned variable + * and the assigned expression - what TypeSpecifier::create() recovered + * by unwrapping assign chains. Nested assignments compose through the + * assigned expression's own result. + * + * @return Closure(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes + */ + private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult): Closure + { + return function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $assignedExprResult): SpecifiedTypes { + $types = $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, $type, $context); + + return $types->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->expr, $assignedExprResult, $type, $context), + ); + }; + } + + /** + * New-world copy of the non-null contexts of specifyTypes(): the assigned + * variable narrows by the boolean outcome, plus the $arr[$key] inference + * after $key = array_key_first/array_key_last/array_search/array_find_key. + * The null-context inferences stay in specifyTypes() - result-based asks + * are always truthy or falsey. + * + * @return Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(Assign $expr): Closure + { + return function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($context->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); + + // infer $arr[$key] after $key = array_key_first/last($arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->expr->getArgs()[0]->value; + $arrayType = $s->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $arrayArg, null, new NonEmptyArrayType(), TypeSpecifierContext::createTrue()), + ); + $isNonEmpty = true; + } else { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); + } + + if ($isNonEmpty) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $s->getType($expr->expr); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), + ); + } + } + } + } + + // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && count($expr->expr->getArgs()) >= 2 + ) { + $funcName = $expr->expr->name->toLowerString(); + $arrayArg = null; + $sentinelType = null; + $isStrictArraySearch = false; + + if ($funcName === 'array_search') { + $arrayArg = $expr->expr->getArgs()[1]->value; + $sentinelType = new ConstantBooleanType(false); + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $s->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); + } elseif ($funcName === 'array_find_key') { + $arrayArg = $expr->expr->getArgs()[0]->value; + $sentinelType = new NullType(); + } + + if ($arrayArg !== null) { + $arrayType = $s->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $arrayArg, null, new NonEmptyArrayType(), TypeSpecifierContext::createTrue()), + ); + + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + if ($isStrictArraySearch) { + $needleType = $s->getType($expr->expr->getArgs()[0]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); + } + + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $dimFetchType, TypeSpecifierContext::createTrue()), + ); + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $s->getType($expr->expr); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + if ($isStrictArraySearch) { + $needleType = $s->getType($expr->expr->getArgs()[0]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); + } + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), + ); + } + } + } + } + } + + return $specifiedTypes; + }; + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 1a0407ce27c..4f8731e6ef6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -36,6 +37,7 @@ final class CoalesceHandler implements TypeResolvingExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -143,6 +145,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), + // a type constraint on the coalesce constrains its left side when + // the type rules the right side in or out - what + // TypeSpecifier::create() recovered by unwrapping the coalesce + createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + if (!$context->null()) { + $rightType = $rightResult->getTypeForScope($s); + if ( + ($context->true() && $type->isSuperTypeOf($rightType)->no()) + || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) + ) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, $type, $context); + } + } + + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context); + }, ); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php new file mode 100644 index 00000000000..a82899110bb --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,91 @@ +` - they + * emit the plain-chain variant alongside their own key once, and every parent + * simply composes their results. No recursive chain-walking, no type ask. + */ +#[AutowiredService] +final class DefaultNarrowingHelper +{ + + public function __construct(private ExprPrinter $exprPrinter) + { + } + + public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + if ($context->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return (new SpecifiedTypes(sureNotTypes: [ + $this->exprPrinter->printExpr($expr) => [$expr, $removedType], + ]))->setRootExpr($expr); + } + + /** + * A greatly simplified TypeSpecifier::create() for a subject the calling + * handler has already processed: one sure (truthy) or sureNot (falsey) + * entry for the subject node. A coalesce subject narrows its left side + * when the narrowed type rules the right side in or out. No purity gates, + * no nullsafe chain-walking, no assignment fan-out - an entry about an + * assignment narrows the assigned variables in the appliers, and the + * subject's own narrowing composes in through + * ExpressionResult::getSpecifiedTypesForScope() at the call site. + */ + /** + * A greatly simplified TypeSpecifier::create() for a subject the calling + * handler has already processed: the subject's own result says how a type + * constraint on it translates into entries (an assignment fans out to the + * assigned variable, a coalesce delegates to its left side); without a + * createTypesCallback a single sure (truthy) or sureNot (falsey) entry + * for the subject node is emitted. No purity gates, no nullsafe + * chain-walking, no structural unwrapping - the handlers that own those + * nodes compose their children's results inside-out. + */ + public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { + if ($subjectResult !== null) { + $createdTypes = $subjectResult->getCreatedTypesForScope($s, $type, $context); + if ($createdTypes !== null) { + return $createdTypes; + } + } + + $exprString = $this->exprPrinter->printExpr($subject); + if ($context->true()) { + return new SpecifiedTypes([$exprString => [$subject, $type]], []); + } + if ($context->false()) { + return new SpecifiedTypes(sureNotTypes: [$exprString => [$subject, $type]]); + } + + return new SpecifiedTypes([], []); + } + +} diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index ff8bda6c552..aaaa04fbed1 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -11,10 +11,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\BooleanType; @@ -40,7 +40,7 @@ final class InstanceofHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -146,7 +146,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } else { $type = new ObjectType($className); } - return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } $classNameType = $classResult !== null @@ -162,16 +162,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $type, new ObjectWithoutClassType(), ); - return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } elseif ($context->false() && !$uncertainty) { $exprType = $exprResult->getTypeForScope($s); if (!$type->isSuperTypeOf($exprType)->yes()) { - return $this->typeSpecifier->create($exprNode, $type, $context, $s)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } } } if ($context->true()) { - return $this->typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $context, $s)->setRootExpr($exprNode); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, new ObjectWithoutClassType(), $context)->setRootExpr($exprNode); } return (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 1fc9923355f..03692785a53 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -13,12 +13,12 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; @@ -38,7 +38,7 @@ final class VariableHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -123,7 +123,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: self::createTypeCallback($expr, $nameResult), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->typeSpecifier->specifyDefaultTypes($s, $expr, $context), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 411e1edeefc..d1e1949d8bc 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -18,6 +18,9 @@ final class ExpressionResult /** @var (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ private $specifyTypesCallback; + /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ + private $createTypesCallback; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -37,6 +40,7 @@ final class ExpressionResult * @param ImpurePoint[] $impurePoints * @param (callable(MutatingScope, Expr): Type)|null $typeCallback * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback + * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback */ @@ -53,12 +57,14 @@ public function __construct( ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, ?callable $specifyTypesCallback = null, + ?callable $createTypesCallback = null, ) { $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; $this->typeCallback = $typeCallback; $this->specifyTypesCallback = $specifyTypesCallback; + $this->createTypesCallback = $createTypesCallback; } public function getScope(): MutatingScope @@ -189,6 +195,25 @@ public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierCon return ($this->specifyTypesCallback)($scope, $context); } + /** + * How a type constraint on this expression translates into narrowing + * entries - the inside-out counterpart of TypeSpecifier::create(). The + * handler that produced this result knows the structure: an assignment + * fans out to the assigned variable and the assigned expression + * (recursing through the assigned expression's own result), a coalesce + * delegates to its left side when the type rules the right side in or + * out. Returns null when the handler wired no createTypesCallback - the + * caller emits a single entry for the expression itself. + */ + public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSpecifierContext $context): ?SpecifiedTypes + { + if ($this->createTypesCallback === null) { + return null; + } + + return ($this->createTypesCallback)($scope, $type, $context); + } + /** * Re-evaluates the expression type on a different scope (e.g. a narrowed one). * Unlike getType(), the result is not cached. diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 43926896f5f..83172cd7eb6 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -15,6 +15,7 @@ interface ExpressionResultFactory * @param (callable(): MutatingScope)|null $falseyScopeCallback * @param (callable(MutatingScope, Expr): Type)|null $typeCallback * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback + * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ public function create( MutatingScope $scope, @@ -28,6 +29,7 @@ public function create( ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, ?callable $specifyTypesCallback = null, + ?callable $createTypesCallback = null, ): ExpressionResult; } From 2e3e59d8b460f292874da16d24619b240fd53343 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 00:02:06 +0200 Subject: [PATCH 019/378] Coalesce, Ternary, BooleanAnd, BooleanOr stop implementing TypeResolvingExprHandler The composite handlers wire typeCallback and specifyTypesCallback composed from their operands' results. This deletes the founding pathology of the rewrite: BooleanAndHandler::resolveType's re-walk of the left operand on a throwaway storage, BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH and both flattened-chain code paths - deep chains compose through nested results. Child narrowing flows through DefaultNarrowingHelper::getChildSpecifiedTypes(): the child result's specifyTypesCallback first, bridged through the old-world dispatcher for unmigrated children (the dispatcher answers converted handlers from stored results, so the bridge terminates; it dies in 3.0). The ternary still rewrites itself as (cond && if) || (!cond && else) - the synthetic takes the on-demand path where its real subnodes answer from stored results. Lessons the conversion forced out of the engine: - A handler must never ask the scope about its own node mid-processing - no stored result exists yet, so the ask takes the on-demand path and recurses infinitely (CoalesceHandler's filterByFalseyValue($expr) for the right-side scope hung the suite). The equivalent narrowing is built directly from the left result instead. - Composite typeCallbacks evaluate later operands on their captured processing scopes. Re-filtering the asking scope loses the left side's side effects (by-ref writes, inline assignments); the child result's own point breaks synthetic compositions (min()'s $a < $b ? $a : $b reuses stored results of the real arg nodes, predating the synthetic's branch narrowing). The captured scope has both; native asks flavor it with doNotTreatPhpDocTypesAsCertain(). - ExpressionResult::getType()/getNativeType()/getTypeForScope() consult tracked expression holders before the typeCallback, mirroring the early return in MutatingScope::resolveType() - that is how the nullsafe handlers' ensured non-nullability of ($x ?? null) reaches type asks. Co-Authored-By: Claude Fable 5 --- .../ExprHandler/BooleanAndHandler.php | 285 ++++++----------- src/Analyser/ExprHandler/BooleanOrHandler.php | 296 +++++------------- src/Analyser/ExprHandler/CoalesceHandler.php | 138 ++++---- .../Helper/DefaultNarrowingHelper.php | 27 +- src/Analyser/ExprHandler/TernaryHandler.php | 132 ++++---- src/Analyser/ExpressionResult.php | 21 +- 6 files changed, 347 insertions(+), 552 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 7ceb06af1bd..3e6854b175c 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -4,48 +4,39 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; -use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\LogicalAnd; -use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\BooleanAndNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function array_merge; -use function array_reverse; use function is_string; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class BooleanAndHandler implements TypeResolvingExprHandler +final class BooleanAndHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -55,183 +46,6 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanAnd || $expr instanceof LogicalAnd; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - if ($leftBooleanType->isFalse()->yes()) { - return new ConstantBooleanType(false); - } - - if (self::getBooleanExpressionDepth($expr->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - $leftResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->left), $expr->left, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - $rightBooleanType = $leftResult->getTruthyScope()->getType($expr->right)->toBoolean(); - } else { - $rightBooleanType = $scope->filterByTruthyValue($expr->left)->getType($expr->right)->toBoolean(); - } - - if ($rightBooleanType->isFalse()->yes()) { - return new ConstantBooleanType(false); - } - - if ( - $leftBooleanType->isTrue()->yes() - && $rightBooleanType->isTrue()->yes() - ) { - return new ConstantBooleanType(true); - } - - return new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - // For deep BooleanAnd chains in truthy context, flatten and - // process all arms at once to avoid O(N²) recursive - // filterByTruthyValue calls. - if ( - $context->true() - && self::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH - ) { - return $this->specifyTypesForFlattenedBooleanAnd($typeSpecifier, $scope, $expr, $context); - } - - $leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); - $rightScope = $scope->filterByTruthyValue($expr->left); - $rightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - if ($context->true()) { - $types = $leftTypes->unionWith($rightTypes); - } else { - $leftNormalized = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); - } - if ($context->false()) { - // Consequent (holder) narrowings projected by each holder: these must be - // the genuine falsey narrowing of the arm. When that is empty, the arm - // has no sound falsey narrowing and must not contribute a consequent. - $leftHolderTypes = $leftTypes; - $rightHolderTypes = $rightTypes; - // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. - if ($context->truthy()) { - if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { - $leftHolderTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { - $rightHolderTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - } - // Condition (antecedent) narrowings: when an arm has no falsey narrowing - // (e.g. isset() on an array dim fetch), derive the condition from the truthy - // narrowing by swapping sure/sureNot types. This swap is only sound for the - // antecedent — processBooleanConditionalTypes inverts it back to the truthy - // narrowing. It must NOT feed the consequent: inverting a comparison's truthy - // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would - // over-narrow the consequent (see regression for `$x === $nonConstantString`). - $leftCondTypes = $leftHolderTypes; - $rightCondTypes = $rightHolderTypes; - if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === []) { - $truthyLeftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); - } - } - if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { - $truthyRightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); - } - } - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightCondTypes, $leftHolderTypes, false, true, $scope, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightCondTypes, $leftHolderTypes, true, true, $scope, $expr->left), - ]))->setRootExpr($expr); - } - - return $types; - } - - public static function getBooleanExpressionDepth(Expr $expr, int $depth = 0): int - { - while ( - $expr instanceof BooleanOr - || $expr instanceof LogicalOr - || $expr instanceof BooleanAnd - || $expr instanceof LogicalAnd - ) { - return self::getBooleanExpressionDepth($expr->left, $depth + 1); - } - - return $depth; - } - - /** - * Flatten a deep BooleanAnd chain into leaf expressions and process them - * without recursive filterByTruthyValue calls. - * - * @param BooleanAnd|LogicalAnd $expr - */ - private function specifyTypesForFlattenedBooleanAnd( - TypeSpecifier $typeSpecifier, - MutatingScope $scope, - Expr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes - { - $arms = []; - $current = $expr; - while ($current instanceof BooleanAnd || $current instanceof LogicalAnd) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; - $arms = array_reverse($arms); - - // Truthy: all arms are true → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($expr); - } - private function allExpressionsTrackable(SpecifiedTypes $types): bool { foreach ($types->getSureTypes() as [$expr]) { @@ -283,6 +97,95 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), + typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftTruthyScope): Type { + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + // the right side was processed on the left-truthy scope including + // the left's side effects (assignments, by-ref writes) - that + // captured scope is the evaluation point, no re-walk and no + // depth cap needed + $rightBooleanType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $leftTruthyScope->doNotTreatPhpDocTypesAsCertain() : $leftTruthyScope)->toBoolean(); + if ($rightBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $leftBooleanType->isTrue()->yes() + && $rightBooleanType->isTrue()->yes() + ) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult): SpecifiedTypes { + $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); + $rightScope = $s->filterByTruthyValue($expr->left); + $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); + if ($context->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $leftNormalized = $leftTypes->normalize($s); + $rightNormalized = $rightTypes->normalize($rightScope); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); + } + if ($context->false()) { + // Consequent (holder) narrowings projected by each holder: these must be + // the genuine falsey narrowing of the arm. When that is empty, the arm + // has no sound falsey narrowing and must not contribute a consequent. + $leftHolderTypes = $leftTypes; + $rightHolderTypes = $rightTypes; + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. + if ($context->truthy()) { + if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { + $leftHolderTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, TypeSpecifierContext::createFalsey())->setRootExpr($expr); + } + if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { + $rightHolderTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, TypeSpecifierContext::createFalsey())->setRootExpr($expr); + } + } + // Condition (antecedent) narrowings: when an arm has no falsey narrowing + // (e.g. isset() on an array dim fetch), derive the condition from the truthy + // narrowing by swapping sure/sureNot types. This swap is only sound for the + // antecedent — processBooleanConditionalTypes inverts it back to the truthy + // narrowing. It must NOT feed the consequent: inverting a comparison's truthy + // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would + // over-narrow the consequent (see regression for `$x === $nonConstantString`). + $leftCondTypes = $leftHolderTypes; + $rightCondTypes = $rightHolderTypes; + if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === []) { + $truthyLeftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { + $truthyRightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightCondTypes, $leftHolderTypes, false, true, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightCondTypes, $leftHolderTypes, true, true, $s, $expr->left), + ]))->setRootExpr($expr); + } + + return $types; + }, ); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index fd32e84aa4b..ae4ff6c712b 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -10,18 +10,15 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\BooleanOrNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; @@ -30,22 +27,18 @@ use function array_key_first; use function array_key_last; use function array_merge; -use function array_reverse; -use function count; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class BooleanOrHandler implements TypeResolvingExprHandler +final class BooleanOrHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -55,213 +48,6 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanOr || $expr instanceof LogicalOr; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // For deep BooleanOr chains, resolve the boolean type by iterating the flattened arms while - // threading the falsey scope, instead of recursing into the left operand and re-narrowing the - // whole chain at each level - the latter is O(n^2) (and worse) in the number of arms. - if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - return $this->resolveTypeForFlattenedBooleanOr($scope, $expr); - } - - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - if ($leftBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - - if (BooleanAndHandler::getBooleanExpressionDepth($expr->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - $leftResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->left), $expr->left, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - $rightBooleanType = $leftResult->getFalseyScope()->getType($expr->right)->toBoolean(); - } else { - $rightBooleanType = $scope->filterByFalseyValue($expr->left)->getType($expr->right)->toBoolean(); - } - - if ($rightBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - - if ( - $leftBooleanType->isFalse()->yes() - && $rightBooleanType->isFalse()->yes() - ) { - return new ConstantBooleanType(false); - } - - return new BooleanType(); - } - - /** - * The whole chain is true if any arm is true (given the previous arms are false), false if every - * arm is false, and bool otherwise. Threading the falsey scope arm by arm keeps this O(n), matching - * the recursive resolveType() result without re-narrowing the whole left chain at each level. - * - * @param BooleanOr|LogicalOr $expr - */ - private function resolveTypeForFlattenedBooleanOr(MutatingScope $scope, Expr $expr): Type - { - $arms = []; - $current = $expr; - while ($current instanceof BooleanOr || $current instanceof LogicalOr) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; - $arms = array_reverse($arms); - - $allArmsAreFalse = true; - $armScope = $scope; - $lastArmKey = array_key_last($arms); - foreach ($arms as $key => $arm) { - $armBooleanType = $armScope->getType($arm)->toBoolean(); - if ($armBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - if (!$armBooleanType->isFalse()->yes()) { - $allArmsAreFalse = false; - } - if ($key === $lastArmKey) { - continue; - } - $armScope = $armScope->filterByFalseyValue($arm); - } - - return $allArmsAreFalse ? new ConstantBooleanType(false) : new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - // For deep BooleanOr chains, flatten and process all arms at once - // to avoid O(n^2) recursive filterByFalseyValue calls - if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - return $this->specifyTypesForFlattenedBooleanOr($typeSpecifier, $scope, $expr, $context); - } - - $leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); - $rightScope = $scope->filterByFalseyValue($expr->left); - $rightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - - if ($context->true()) { - if ( - $scope->getType($expr->left)->toBoolean()->isFalse()->yes() - ) { - $types = $rightTypes->normalize($rightScope); - } elseif ( - $scope->getType($expr->left)->toBoolean()->isTrue()->yes() - || $scope->getType($expr->right)->toBoolean()->isFalse()->yes() - ) { - $types = $leftTypes->normalize($scope); - } else { - $leftNormalized = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->augmentBooleanOrTruthyWithConditionalHolders($typeSpecifier, $scope, $rightScope, $expr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); - } - } else { - $types = $leftTypes->unionWith($rightTypes); - } - - if ($context->true()) { - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightTypes, $leftTypes, false, false, $scope, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightTypes, $leftTypes, true, false, $scope, $expr->left), - ]))->setRootExpr($expr); - } - - return $types; - } - - /** - * Flatten a deep BooleanOr chain into leaf expressions and process them - * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) - * for chains with many arms (e.g., 80+ === comparisons in ||). - */ - private function specifyTypesForFlattenedBooleanOr( - TypeSpecifier $typeSpecifier, - MutatingScope $scope, - BooleanOr|LogicalOr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes - { - // Collect all leaf expressions from the chain - $arms = []; - $current = $expr; - while ($current instanceof BooleanOr || $current instanceof LogicalOr) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; // leftmost leaf - $arms = array_reverse($arms); - - if ($context->false() || $context->falsey()) { - // Falsey: all arms are false → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental TypeCombinator::union() growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::intersect(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($expr); - } - - // Truthy: at least one arm is true → intersect all normalized SpecifiedTypes - $armSpecifiedTypes = []; - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - $armSpecifiedTypes[] = $armTypes->normalize($scope); - } - - $types = $armSpecifiedTypes[0]; - for ($i = 1; $i < count($armSpecifiedTypes); $i++) { - $types = $types->intersectWith($armSpecifiedTypes[$i]); - } - - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - - return $result->setRootExpr($expr); - } - /** * For `if ($a || $b)` truthy, expressions narrowed by stored conditional * holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is @@ -280,7 +66,7 @@ private function specifyTypesForFlattenedBooleanOr( * skipped: in the OR-truthy scope the arm that didn't narrow could still be * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function augmentBooleanOrTruthyWithConditionalHolders(TypeSpecifier $typeSpecifier, MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes + private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes { $leftTruthyScope = $scope->filterByTruthyValue($expr->left); $rightTruthyScope = $rightScope->filterByTruthyValue($expr->right); @@ -329,7 +115,7 @@ private function augmentBooleanOrTruthyWithConditionalHolders(TypeSpecifier $typ } $types = $types->unionWith( - $typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), + $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()), ); } } @@ -361,6 +147,74 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), + typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftFalseyScope): Type { + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + // the right side was processed on the left-falsey scope including + // the left's side effects (assignments, by-ref writes) - that + // captured scope is the evaluation point, no re-walk and no + // depth cap needed + $rightBooleanType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $leftFalseyScope->doNotTreatPhpDocTypesAsCertain() : $leftFalseyScope)->toBoolean(); + if ($rightBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ( + $leftBooleanType->isFalse()->yes() + && $rightBooleanType->isFalse()->yes() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult): SpecifiedTypes { + $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); + $rightScope = $s->filterByFalseyValue($expr->left); + $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); + + if ($context->true()) { + if ( + $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes->normalize($rightScope); + } elseif ( + $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() + || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes->normalize($s); + } else { + $leftNormalized = $leftTypes->normalize($s); + $rightNormalized = $rightTypes->normalize($rightScope); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders($s, $rightScope, $expr, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($context->true()) { + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightTypes, $leftTypes, false, false, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightTypes, $leftTypes, true, false, $s, $expr->left), + ]))->setRootExpr($expr); + } + + return $types; + }, ); } diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 4f8731e6ef6..27c0fc4a889 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -9,17 +9,14 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; @@ -28,10 +25,10 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class CoalesceHandler implements TypeResolvingExprHandler +final class CoalesceHandler implements ExprHandler { public function __construct( @@ -47,76 +44,22 @@ public function supports(Expr $expr): bool return $expr instanceof Coalesce; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * A falsey coalesce means its left side was null (when it was surely set) - + * shared by the specifyTypesCallback and by processExpr() for the scope + * the right side evaluates under. + * + * @param Coalesce $expr + */ + private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, ExpressionResult $condResult, TypeSpecifierContext $context): SpecifiedTypes { - $issetLeftExpr = new Expr\Isset_([$expr->left]); + $isset = $s->issetCheck($expr->left, static fn () => true); - $result = $scope->issetCheck($expr->left, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - return !$isNull->yes(); - }); - - if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($scope->filterByTruthyValue($issetLeftExpr)->getType($expr->left)); - } - - $rightType = $scope->filterByFalseyValue($issetLeftExpr)->getType($expr->right); - - if ($result === null) { - return TypeCombinator::union( - TypeCombinator::removeNull($scope->filterByTruthyValue($issetLeftExpr)->getType($expr->left)), - $rightType, - ); - } - - return $rightType; - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if (!$context->true()) { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($expr->left, static fn () => true); - - if ($isset !== true) { - return new SpecifiedTypes(); - } - - return $typeSpecifier->create( - $expr->left, - new NullType(), - $context->negate(), - $scope, - )->setRootExpr($expr); - } - - if ( - !$context->falsey() - && (new ConstantBooleanType(false))->isSuperTypeOf($scope->getType($expr->right)->toBoolean())->yes() - ) { - return $typeSpecifier->create( - $expr->left, - new NullType(), - TypeSpecifierContext::createFalse(), - $scope, - )->setRootExpr($expr); + if ($isset !== true) { + return new SpecifiedTypes(); } - // The Coalesce condition matched but produced no narrowing; the legacy - // if/elseif chain fell through to its empty-SpecifiedTypes tail here, - // not to the truthy/falsey default. - return (new SpecifiedTypes([], []))->setRootExpr($expr); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), $context->negate())->setRootExpr($expr); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -128,7 +71,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); - $rightScope = $scope->filterByFalseyValue($expr); + // the falsey narrowing of this very node - asking the scope about it + // mid-processing would take the on-demand path and recurse + $rightScope = $scope->applySpecifiedTypes($this->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); $rightExprType = $scope->getType($expr->right); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { @@ -145,6 +90,53 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), + typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope): Type { + $issetLeftExpr = new Expr\Isset_([$expr->left]); + + $result = $s->issetCheck($expr->left, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result !== null && $result !== false) { + return TypeCombinator::removeNull($condResult->getTypeForScope($s->filterByTruthyValue($issetLeftExpr))); + } + + // the right side was processed on the left-is-null scope - that + // captured scope is the evaluation point + $rightType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $rightScope->doNotTreatPhpDocTypesAsCertain() : $rightScope); + + if ($result === null) { + return TypeCombinator::union( + TypeCombinator::removeNull($condResult->getTypeForScope($s->filterByTruthyValue($issetLeftExpr))), + $rightType, + ); + } + + return $rightType; + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + if (!$context->true()) { + return $this->getFalseySpecifiedTypes($s, $expr, $condResult, $context); + } + + if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeForScope($s)->toBoolean())->yes()) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); + } + + // The Coalesce condition matched but produced no narrowing; the legacy + // if/elseif chain fell through to its empty-SpecifiedTypes tail here, + // not to the truthy/falsey default. + return (new SpecifiedTypes([], []))->setRootExpr($expr); + }, // a type constraint on the coalesce constrains its left side when // the type rules the right side in or out - what // TypeSpecifier::create() recovered by unwrapping the coalesce diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index a82899110bb..84395b4b3d3 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -25,10 +26,34 @@ final class DefaultNarrowingHelper { - public function __construct(private ExprPrinter $exprPrinter) + public function __construct( + private ExprPrinter $exprPrinter, + private TypeSpecifier $typeSpecifier, + ) { } + /** + * The narrowing of an already-processed child expression in the given + * boolean context: answered by the child result's specifyTypesCallback. + * Until the child's handler migrates its narrowing - or when the child + * is a synthetic node with no result - this bridges through the + * old-world dispatcher, which answers converted handlers from stored + * results, so the bridge terminates. The bridge dies in 3.0 together + * with TypeSpecifier::specifyTypesInCondition(). + */ + public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?ExpressionResult $childResult, TypeSpecifierContext $context): SpecifiedTypes + { + if ($childResult !== null) { + $types = $childResult->getSpecifiedTypesForScope($s, $context); + if ($types !== null) { + return $types; + } + } + + return $this->typeSpecifier->specifyTypesInCondition($s, $childExpr, $context); + } + public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes { if ($context->null()) { diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 9aa0142bc24..a202f7d90c5 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -11,13 +11,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NeverType; @@ -26,15 +24,15 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class TernaryHandler implements TypeResolvingExprHandler +final class TernaryHandler implements ExprHandler { public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -44,62 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof Ternary; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $condResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->cond), $expr->cond, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - if ($expr->if === null) { - $conditionType = $scope->getType($expr->cond); - $booleanConditionType = $conditionType->toBoolean(); - if ($booleanConditionType->isTrue()->yes()) { - return $condResult->getTruthyScope()->getType($expr->cond); - } - - if ($booleanConditionType->isFalse()->yes()) { - return $condResult->getFalseyScope()->getType($expr->else); - } - - return TypeCombinator::union( - TypeCombinator::removeFalsey($condResult->getTruthyScope()->getType($expr->cond)), - $condResult->getFalseyScope()->getType($expr->else), - ); - } - - $booleanConditionType = $scope->getType($expr->cond)->toBoolean(); - if ($booleanConditionType->isTrue()->yes()) { - return $condResult->getTruthyScope()->getType($expr->if); - } - - if ($booleanConditionType->isFalse()->yes()) { - return $condResult->getFalseyScope()->getType($expr->else); - } - - return TypeCombinator::union( - $condResult->getTruthyScope()->getType($expr->if), - $condResult->getFalseyScope()->getType($expr->else), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr->cond instanceof Ternary || $context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if ($expr->if !== null) { - $conditionExpr = new BooleanOr( - new BooleanAnd($expr->cond, $expr->if), - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), - ); - } else { - $conditionExpr = new BooleanOr( - $expr->cond, - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), - ); - } - - return $typeSpecifier->specifyTypesInCondition($scope, $conditionExpr, $context)->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $ternaryCondResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -109,7 +51,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifTrueScope = $ternaryCondResult->getTruthyScope(); $ifFalseScope = $ternaryCondResult->getFalseyScope(); $ifTrueType = null; + $ifResult = null; + $ifProcessingScope = $ifTrueScope; + $elseProcessingScope = $ifFalseScope; if ($expr->if === null) { $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -158,6 +103,67 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, + // the branches were processed on the cond-truthy/cond-falsey scopes + // including the condition's side effects - those captured scopes + // are the evaluation points, no re-walk needed + typeCallback: static function (MutatingScope $s) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope): Type { + if ($s->nativeTypesPromoted) { + $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); + $elseProcessingScope = $elseProcessingScope->doNotTreatPhpDocTypesAsCertain(); + } + $booleanConditionType = $ternaryCondResult->getTypeForScope($s)->toBoolean(); + $elseType = $elseResult->getTypeForScope($elseProcessingScope); + if ($expr->if === null || $ifResult === null) { + $condTruthyType = $ternaryCondResult->getTypeForScope($ifProcessingScope); + if ($booleanConditionType->isTrue()->yes()) { + return $condTruthyType; + } + + if ($booleanConditionType->isFalse()->yes()) { + return $elseType; + } + + return TypeCombinator::union( + TypeCombinator::removeFalsey($condTruthyType), + $elseType, + ); + } + + $ifType = $ifResult->getTypeForScope($ifProcessingScope); + if ($booleanConditionType->isTrue()->yes()) { + return $ifType; + } + + if ($booleanConditionType->isFalse()->yes()) { + return $elseType; + } + + return TypeCombinator::union( + $ifType, + $elseType, + ); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($expr->cond instanceof Ternary || $context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + if ($expr->if !== null) { + $conditionExpr = new BooleanOr( + new BooleanAnd($expr->cond, $expr->if), + new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), + ); + } else { + $conditionExpr = new BooleanOr( + $expr->cond, + new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), + ); + } + + // the synthetic condition takes the on-demand bridge; its real + // subnodes answer from stored results + return $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $conditionExpr, null, $context)->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index d1e1949d8bc..43de235c169 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -156,7 +156,7 @@ public function getType(): Type } } - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope, $this->expr)); } @@ -169,13 +169,28 @@ public function getNativeType(): Type return $this->cachedNativeType; } - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); } return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); } + /** + * A narrowed or ensured type tracked for the whole expression (e.g. the + * nullsafe handlers ensure `($x ?? null)` is not null before processing + * the chain) wins over recomputing the type - mirrors the tracked-holder + * early return in MutatingScope::resolveType(). Asking the scope is safe: + * its own early return answers from the holder without dispatching back. + */ + private function hasTrackedExpressionType(MutatingScope $scope): bool + { + return !$this->expr instanceof Expr\Variable + && !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes(); + } + public function hasTypeCallback(): bool { return $this->typeCallback !== null; @@ -220,7 +235,7 @@ public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSp */ public function getTypeForScope(MutatingScope $scope): Type { - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); } From db711db7de6ca19588036897d8332a477055ff5e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 00:11:12 +0200 Subject: [PATCH 020/378] Add regression test for conditional holders narrowing coalesce of properties Closes https://github.com/phpstan/phpstan/issues/10786 Co-Authored-By: Claude Fable 5 --- tests/PHPStan/Analyser/nsrt/bug-10786.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10786.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10786.php b/tests/PHPStan/Analyser/nsrt/bug-10786.php new file mode 100644 index 00000000000..4dc9333aba6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10786.php @@ -0,0 +1,23 @@ +value) && is_null($b->value)) { + throw new \Exception(); + } + + assertType('int', $a->value ?? $b->value); + + return $a->value ?? $b->value; + } +} From 220145891f0c06b748fa218359acc93a9153af56 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 00:19:31 +0200 Subject: [PATCH 021/378] Never process expressions on a FiberScope The engine never processes on the rule-facing FiberScope - its type asks suspend, which crashes outside a fiber. One can reach processExprNode through a stored result's memoized truthy/falsey scope (first computed inside a rule fiber) consumed by a composite handler for a child's processing scope: phpstan-phpunit's assertEmpty() extension builds a synthetic BooleanOr, the converted BooleanOrHandler processes it on demand, and the right arm's scope comes from the left arm's stored result. Convert to the mutating flavor at the processExprNode boundary. Co-Authored-By: Claude Fable 5 --- src/Analyser/NodeScopeResolver.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c7893644241..aa0e39860ed 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -52,6 +52,7 @@ use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; +use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -2841,6 +2842,15 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { + if ($scope instanceof FiberScope) { + // the engine never processes on the rule-facing FiberScope - one can + // arrive here through a stored result's memoized truthy/falsey scope + // (first computed inside a rule fiber) consumed by a handler for a + // child's processing scope; its type asks would suspend outside + // a fiber + $scope = $scope->toMutatingScope(); + } + if ($this->returnStoredExpressionResults) { $storedResult = $storage->findExpressionResult($expr); if ($storedResult !== null) { From 958d1cce7166ddcfecc4285bbb35fcb6c04f591a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 00:36:03 +0200 Subject: [PATCH 022/378] Convert rule-facing FiberScope at the new-world hook boundary Replaces the blanket processExprNode conversion with the root cause: the hooks are the boundary between the rule-facing world and the engine. Rules hold FiberScopes and feed them straight into the old-world dispatcher (ImpossibleCheckTypeHelper passes the rule's scope to specifyTypesInCondition, phpstan-phpunit's assert extension builds a synthetic BooleanOr there), so resolveTypeOfNewWorldHandlerNode() and specifyTypesOfNewWorldHandlerNode() can run with $this being a FiberScope. They now call toMutatingScope() - identity on a plain scope, a state-preserving copy on a FiberScope - before invoking result callbacks and on-demand processing. Without the conversion the engine processes synthetic nodes on the rule-facing scope, whose type asks suspend: wasteful inside a rule fiber, fatal outside one ("Cannot suspend outside of a fiber"). Found by an ExpressionResult creation tripwire after the CI-only crash never reproduced locally. Co-Authored-By: Claude Fable 5 --- src/Analyser/MutatingScope.php | 21 +++++++++++++++------ src/Analyser/NodeScopeResolver.php | 10 ---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f9436dfeaca..793b0f9eaaf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1093,6 +1093,11 @@ private function resolveType(string $exprString, Expr $node): Type */ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type { + // the hooks are the boundary between the rule-facing world and the + // engine - a rule's FiberScope must not flow into result callbacks or + // on-demand processing, where its suspending type asks crash outside + // a fiber + $scope = $this->toMutatingScope(); $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage !== null) { $result = $storage->findExpressionResult($node); @@ -1104,18 +1109,18 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type )); } - return $result->getTypeForScope($this); + return $result->getTypeForScope($scope); } } // a synthetic node, or no analysis in progress $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, - $this, + $scope, $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), ); - return $onDemandResult->getTypeForScope($this); + return $onDemandResult->getTypeForScope($scope); } /** @@ -1158,22 +1163,26 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array */ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): ?SpecifiedTypes { + // see resolveTypeOfNewWorldHandlerNode() - rules ask the dispatcher + // with their FiberScope (e.g. ImpossibleCheckTypeHelper), the engine + // side of the boundary works with the mutating flavor + $scope = $this->toMutatingScope(); $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage !== null) { $result = $storage->findExpressionResult($node); if ($result !== null) { - return $result->getSpecifiedTypesForScope($this, $context); + return $result->getSpecifiedTypesForScope($scope, $context); } } // a synthetic node, or no analysis in progress $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, - $this, + $scope, $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), ); - return $onDemandResult->getSpecifiedTypesForScope($this, $context); + return $onDemandResult->getSpecifiedTypesForScope($scope, $context); } /** diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index aa0e39860ed..c7893644241 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -52,7 +52,6 @@ use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; -use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -2842,15 +2841,6 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { - if ($scope instanceof FiberScope) { - // the engine never processes on the rule-facing FiberScope - one can - // arrive here through a stored result's memoized truthy/falsey scope - // (first computed inside a rule fiber) consumed by a handler for a - // child's processing scope; its type asks would suspend outside - // a fiber - $scope = $scope->toMutatingScope(); - } - if ($this->returnStoredExpressionResults) { $storedResult = $storage->findExpressionResult($expr); if ($storedResult !== null) { From a5307f0daf69fe8da3a8b2b363c5d355a97aa142 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 13:26:34 +0200 Subject: [PATCH 023/378] Guard that only synthetic nodes reach the pending-fiber on-demand path processPendingFibers handles fibers suspended on a type ask whose node was never stored during natural traversal. By design those should only be synthetic nodes (built during analysis, no source position) - a real AST node left pending means a rule asked about its type but the producing handler never processed and stored it, so the ask falls back to on-demand processing (correct but wasteful, and on the asker's scope). The guard throws on a non-synthetic node here. It stays dormant by default and fires only with PHPSTAN_GUARD_NW=1, so it is a diagnostic for finding the remaining gaps (e.g. immediately-invoked closures), not a runtime check. Co-Authored-By: Claude Fable 5 --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index e49935f0023..a47f6f2cf42 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -15,7 +15,10 @@ use PHPStan\ShouldNotHappenException; use function array_pop; use function count; +use function get_class; use function get_debug_type; +use function getenv; +use function sprintf; #[AutowiredService(as: FiberNodeScopeResolver::class)] final class FiberNodeScopeResolver extends NodeScopeResolver @@ -114,6 +117,19 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } + // Only synthetic nodes (built during analysis, no source position) + // should reach the on-demand path here. A real AST node left pending + // means a rule asked about its type but it was never processed and + // stored during natural traversal - a gap to fix at the producing + // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. + if (getenv('PHPSTAN_GUARD_NW') === '1' && $request->expr->getStartLine() !== -1) { + throw new ShouldNotHappenException(sprintf( + 'Pending fiber about non-synthetic node %s on line %d - it should have been processed and its result stored during natural traversal.', + get_class($request->expr), + $request->expr->getStartLine(), + )); + } + unset($storage->pendingFibers[$key]); $fiber = $pending['fiber']; From 40067bcf4eafa38d1ec46c68ebe016d52d2d0b0d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 20:03:07 +0200 Subject: [PATCH 024/378] Fix immediately-invoked-closure fiber flush; make the new-world guard accurate Two related changes to the PHPSTAN_GUARD_NW diagnostic and the gap it found: - Immediately-invoked closures: a fiber suspended on the IIFE FuncCall (a rule asked its type) was flushed by a nested statement-list boundary inside the IIFE's own closure body, before the IIFE result was stored - so it took the on-demand path. processExprNode now records the expression it is processing (processingExprIds) and processPendingFibers skips a fiber whose node is still being processed; it is resumed when that processExprNode stores the result. Measured slightly faster on NodeScopeResolverTest (less on-demand reprocessing). - The guard now tells a real AST node from a node built during analysis (rule-constructed comparisons, call_user_func/ArgumentsNormalizer rewrites) by membership in the parsed file (guardRealExprIds, collected once per file only when the flag is set), not by source line - synthetic nodes copy their origin's line, so the previous line check mis-flagged them as gaps. Rule-built synthetics legitimately resolve on demand. Off by default (zero cost). With it on, the only nsrt real-node gap was the IIFE, now fixed; the remaining src gaps (Closure/String_ in arg and key positions, comparison operands) are step-1 work, documented in NEW_WORLD.md. Co-Authored-By: Claude Fable 5 --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 20 +++++-- src/Analyser/NodeScopeResolver.php | 59 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index a47f6f2cf42..186de5fdcb7 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -18,6 +18,7 @@ use function get_class; use function get_debug_type; use function getenv; +use function spl_object_id; use function sprintf; #[AutowiredService(as: FiberNodeScopeResolver::class)] @@ -111,20 +112,31 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void foreach ($storage->pendingFibers as $key => $pending) { $request = $pending['request']; + + // A fiber suspended on an expression that is still being processed + // must not be flushed here: this boundary is a nested statement list + // inside that very expression (e.g. an immediately-invoked closure's + // body). The fiber is resumed when the enclosing processExprNode + // stores the result. + if (isset($this->processingExprIds[spl_object_id($request->expr)])) { + continue; + } + $expressionResult = $storage->findExpressionResult($request->expr); if ($expressionResult !== null) { throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } - // Only synthetic nodes (built during analysis, no source position) - // should reach the on-demand path here. A real AST node left pending + // Only nodes built during analysis (rules constructing synthetic + // comparisons, ArgumentsNormalizer rewrites, ...) should reach the + // on-demand path here. A node from the file's parsed AST left pending // means a rule asked about its type but it was never processed and // stored during natural traversal - a gap to fix at the producing // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. - if (getenv('PHPSTAN_GUARD_NW') === '1' && $request->expr->getStartLine() !== -1) { + if (getenv('PHPSTAN_GUARD_NW') === '1' && isset($this->guardRealExprIds[spl_object_id($request->expr)])) { throw new ShouldNotHappenException(sprintf( - 'Pending fiber about non-synthetic node %s on line %d - it should have been processed and its result stored during natural traversal.', + 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', get_class($request->expr), $request->expr->getStartLine(), )); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c7893644241..63f8cd4a4d4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -175,10 +175,13 @@ use function array_slice; use function array_values; use function count; +use function get_class; +use function getenv; use function in_array; use function is_array; use function is_int; use function is_string; +use function spl_object_id; use function sprintf; use function strtolower; use function trim; @@ -212,6 +215,26 @@ class NodeScopeResolver */ protected bool $returnStoredExpressionResults = false; + /** + * spl_object_id => recursion depth of the expressions currently being + * processed by processExprNode. A fiber pending on one of them must not be + * flushed at a nested statement-list boundary inside that expression - it + * is resumed when the expression's own processing stores its result. + * + * @var array + */ + protected array $processingExprIds = []; + + /** + * spl_object_id => true of every Expr in the file's parsed AST. Populated + * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the pending-fiber + * guard can tell a real AST node (a genuine gap) from a node a rule built + * during analysis (legitimately resolved on demand). + * + * @var array + */ + protected array $guardRealExprIds = []; + /** * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) * @param array $earlyTerminatingFunctionCalls @@ -281,6 +304,13 @@ public function processNodes( callable $nodeCallback, ): void { + if (getenv('PHPSTAN_GUARD_NW') === '1') { + $this->guardRealExprIds = []; + foreach ((new NodeFinder())->findInstanceOf($nodes, Expr::class) as $realExpr) { + $this->guardRealExprIds[spl_object_id($realExpr)] = true; + } + } + $expressionResultStorage = new ExpressionResultStorage(); $scope->pushExpressionResultStorage($expressionResultStorage); try { @@ -2848,6 +2878,35 @@ public function processExprNode( } } + // Track that this expression is being processed. A fiber suspended on it + // (a rule asked its type before processing reached it) must not be + // flushed at a nested statement-list boundary inside this very + // expression - e.g. an immediately-invoked closure's body. It is resumed + // when this processExprNode stores the result below. + $exprId = spl_object_id($expr); + $this->processingExprIds[$exprId] = ($this->processingExprIds[$exprId] ?? 0) + 1; + + try { + return $this->processExprNodeInternal($stmt, $expr, $scope, $storage, $nodeCallback, $context); + } finally { + if (--$this->processingExprIds[$exprId] === 0) { + unset($this->processingExprIds[$exprId]); + } + } + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processExprNodeInternal( + Node\Stmt $stmt, + Expr $expr, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + ExpressionContext $context, + ): ExpressionResult + { if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); From 21899fbcdc349f903c09f514bd9e64b69b0d222f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 20:44:36 +0200 Subject: [PATCH 025/378] Flush pending fibers only at scope boundaries; process dropped call args Fixes the real-AST-node gaps PHPSTAN_GUARD_NW reports across the test suite (closures, compact()/InClassMethodNode, named-argument errors). processStmtNodesInternal used to flush pending fibers at the end of every statement list, including nested control flow (if/else branches, loop and switch/try bodies). A rule invoked at a scope's entry - e.g. UnusedConstructorParametersRule on InClassMethodNode - asks the types of expressions appearing later in the body; flushing at an earlier branch resolved those fibers on the asker's scope before natural traversal stored the results. The flush now happens only when the statement list is a scope body (FunctionLike, ClassLike, Namespace_); nested lists defer to it, and fibers are resumed when their expression stores its result. This is the documented design ("flush only at unit boundaries"). processClosureNode tracks the closure in processingExprIds while its body is processed, so a scope-boundary flush triggered inside the body (a nested closure or anonymous class) does not flush the fiber pending on the enclosing closure itself. processDroppedArgs handles arguments dropped by ArgumentsNormalizer - duplicate, unknown-named or extra arguments in an invalid call. The parameters check still asks their types to report the error, so they are processed (NoopNodeCallback) and stored even though processArgs iterates only the normalized argument list. Co-Authored-By: Claude Fable 5 --- src/Analyser/ExprHandler/FuncCallHandler.php | 1 + .../ExprHandler/MethodCallHandler.php | 1 + src/Analyser/ExprHandler/NewHandler.php | 1 + .../ExprHandler/StaticCallHandler.php | 1 + src/Analyser/NodeScopeResolver.php | 83 ++++++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index ad56dca9dc1..ed893997763 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -279,6 +279,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeBeforeArgs = $scope; $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 071e65cdbdf..08f36effeec 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -151,6 +151,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $context, ); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); if ($methodReflection !== null) { $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 5298f6710db..ddd4ed5bfc4 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -204,6 +204,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $argsResult = $nodeScopeResolver->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $hasYield || $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index b71127b4cc1..a868cdce692 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -217,6 +217,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $scopeFunction = $scope->getFunction(); if ($methodReflection !== null) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 63f8cd4a4d4..d2b7e3bedf3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -585,7 +585,22 @@ private function processStmtNodesInternal( $nodeCallback, $context, ); - $this->processPendingFibers($storage); + // Flush pending fibers only at a scope boundary - a function/method body, + // a class/trait body, a namespace. Nested control-flow statement lists + // (if/else branches, loop and switch/try bodies) must NOT flush: a rule + // invoked at the scope's entry node (e.g. UnusedConstructorParametersRule + // on InClassMethodNode) asks the types of expressions appearing later in + // the body, and a flush at an earlier branch would resolve those fibers + // on the asker's scope before natural traversal stores the results. Such + // fibers are resumed when their expression stores its result, or at this + // scope boundary once the whole body is processed. + if ( + $parentNode instanceof Node\FunctionLike + || $parentNode instanceof Node\Stmt\ClassLike + || $parentNode instanceof Node\Stmt\Namespace_ + ) { + $this->processPendingFibers($storage); + } return $statementResult; } @@ -3086,6 +3101,37 @@ public function processClosureNode( ?Type $passedToType, ?Type $nativePassedToType = null, ): ProcessClosureResult + { + // Closures reached as call arguments are processed here directly rather + // than through processExprNode (which tracks the node), so track the + // closure too: the dependency/node callbacks fired for it ask its type + // and suspend a fiber that must not be flushed at a nested boundary + // inside the closure body before the caller stores the closure result. + $exprId = spl_object_id($expr); + $this->processingExprIds[$exprId] = ($this->processingExprIds[$exprId] ?? 0) + 1; + + try { + return $this->processClosureNodeInternal($stmt, $expr, $scope, $storage, $nodeCallback, $context, $passedToType, $nativePassedToType); + } finally { + if (--$this->processingExprIds[$exprId] === 0) { + unset($this->processingExprIds[$exprId]); + } + } + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processClosureNodeInternal( + Node\Stmt $stmt, + Expr\Closure $expr, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + ExpressionContext $context, + ?Type $passedToType, + ?Type $nativePassedToType = null, + ): ProcessClosureResult { foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -4043,6 +4089,41 @@ public function processArgs( return $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } + /** + * Arguments normalization (reordering, default-filling) can drop an original + * argument from the call processArgs() iterates - duplicate, unknown-named or + * extra arguments in an invalid call. The parameters check still asks their + * types to report the error, so process them too (their result is stored). + * A NoopNodeCallback keeps the dropped arguments out of rule processing, + * matching the behaviour when this guard is off. + */ + public function processDroppedArgs( + Node\Stmt $stmt, + CallLike $originalCall, + CallLike $normalizedCall, + MutatingScope $scope, + ExpressionResultStorage $storage, + ExpressionContext $context, + ): void + { + if ($originalCall === $normalizedCall) { + return; + } + + $keptValueIds = []; + foreach ($normalizedCall->getArgs() as $normalizedArg) { + $keptValueIds[spl_object_id($normalizedArg->value)] = true; + } + + foreach ($originalCall->getArgs() as $originalArg) { + if (isset($keptValueIds[spl_object_id($originalArg->value)])) { + continue; + } + + $this->processExprNode($stmt, $originalArg->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + } + } + /** * @param MethodReflection|FunctionReflection|null $calleeReflection */ From 506e30862cc4d935885a6d88f21c8458907f0acd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 13 Jun 2026 20:51:23 +0200 Subject: [PATCH 026/378] Add PHPSTAN_GUARD_NW guard: no getType on a real node before processExprNode The engine, handlers and extensions must not ask MutatingScope::getType() about a node from the file's parsed AST before processExprNode has processed it. Such an ask resolves the type by re-processing the node out of order - the double-processing that makes a node analysed several times. Tracking is collected per file only when the diagnostic is enabled (cached as NodeScopeResolver::$guardNewWorld): $guardRealExprIds is every parsed-AST Expr, $guardProcessedExprIds is every Expr processExprNode has stored. MutatingScope::getType throws when a real, not-yet-processed node is asked. Rules ask through FiberScope (which suspends and is answered from the result), so this targets only the old-world MutatingScope asks. Off by default - one static bool read per getType when disabled, no measurable cost. The existing pending-fiber guard and the real-node set are reused, now static so MutatingScope can read them. Co-Authored-By: Claude Fable 5 --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 3 +- src/Analyser/MutatingScope.php | 13 ++++++++ src/Analyser/NodeScopeResolver.php | 32 +++++++++++++++---- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 186de5fdcb7..6e7d2cd8df8 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -17,7 +17,6 @@ use function count; use function get_class; use function get_debug_type; -use function getenv; use function spl_object_id; use function sprintf; @@ -134,7 +133,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void // means a rule asked about its type but it was never processed and // stored during natural traversal - a gap to fix at the producing // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. - if (getenv('PHPSTAN_GUARD_NW') === '1' && isset($this->guardRealExprIds[spl_object_id($request->expr)])) { + if (self::$guardNewWorld && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { throw new ShouldNotHappenException(sprintf( 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', get_class($request->expr), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 793b0f9eaaf..dd876c51f9f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -122,6 +122,7 @@ use function is_string; use function ltrim; use function md5; +use function spl_object_id; use function sprintf; use function str_starts_with; use function strlen; @@ -955,6 +956,18 @@ public function getAnonymousFunctionReturnType(): ?Type /** @api */ public function getType(Expr $node): Type { + if ( + NodeScopeResolver::$guardNewWorld + && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) + && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) + ) { + throw new ShouldNotHappenException(sprintf( + 'getType() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + get_class($node), + $node->getStartLine(), + )); + } + $type = ScopeOps::getTypeFromCache($this, $node, $key); if ($type !== null) { return $type; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d2b7e3bedf3..8e8a76c1fb2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -225,15 +225,27 @@ class NodeScopeResolver */ protected array $processingExprIds = []; + /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ + public static bool $guardNewWorld = false; + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated - * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the pending-fiber - * guard can tell a real AST node (a genuine gap) from a node a rule built - * during analysis (legitimately resolved on demand). + * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can + * tell a real AST node from a node a rule built during analysis (which + * legitimately resolves on demand). Static so MutatingScope can read it. + * + * @var array + */ + public static array $guardRealExprIds = []; + + /** + * spl_object_id => true of every Expr already processed by processExprNode + * in the current file. Used by the MutatingScope::getType guard to detect a + * real AST node whose type is asked before it was processed. * * @var array */ - protected array $guardRealExprIds = []; + public static array $guardProcessedExprIds = []; /** * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) @@ -282,6 +294,8 @@ public function __construct( } } $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + + self::$guardNewWorld = getenv('PHPSTAN_GUARD_NW') === '1'; } /** @@ -304,10 +318,11 @@ public function processNodes( callable $nodeCallback, ): void { - if (getenv('PHPSTAN_GUARD_NW') === '1') { - $this->guardRealExprIds = []; + if (self::$guardNewWorld) { + self::$guardRealExprIds = []; + self::$guardProcessedExprIds = []; foreach ((new NodeFinder())->findInstanceOf($nodes, Expr::class) as $realExpr) { - $this->guardRealExprIds[spl_object_id($realExpr)] = true; + self::$guardRealExprIds[spl_object_id($realExpr)] = true; } } @@ -410,6 +425,9 @@ private function processNodesWithStorage( public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { + if (self::$guardNewWorld) { + self::$guardProcessedExprIds[spl_object_id($expr)] = true; + } // converted handlers (no TypeResolvingExprHandler) are answered from // stored results in both worlds - storing must not depend on fibers $storage->storeExpressionResult($expr, $expressionResult); From 93cab090ca986758a45b12079aa68635779a01ed Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:06:15 +0200 Subject: [PATCH 027/378] BitwiseNotHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/BitwiseNotHandler.php | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 5efa2fdef6d..95564a87bf3 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -9,27 +9,27 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class BitwiseNotHandler implements TypeResolvingExprHandler +final class BitwiseNotHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +51,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $exprResult->getTypeForScope($scope); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From ed6f9cff4ad0fd5aa938982a360d3a139679b21b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:18:48 +0200 Subject: [PATCH 028/378] One more should-not --- src/Analyser/ExprHandler/ArrayHandler.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 501fd5074de..35bcd555286 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -19,6 +19,7 @@ use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -98,9 +99,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex : $itemResults[$id]->getType(); } - // getArrayType only asks about item keys and values - guarded - // legacy bridge just in case - return $s->getType($inner); + throw new ShouldNotHappenException(); }); if ( From 7cefb8ed4566ec1b00c8268671409ab8192c44ae Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:19:41 +0200 Subject: [PATCH 029/378] UnaryMinusHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExprHandler/UnaryMinusHandler.php | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index ad84baeee02..b2826e29cb4 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -9,27 +9,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class UnaryMinusHandler implements TypeResolvingExprHandler +final class UnaryMinusHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +50,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $exprResult->getTypeForScope($scope); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType + // operand) - not a child result, resolved on demand + return $scope->getType($e); + }), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From e8ec980f6a3f90bbad87aaae48090b2385dae5b5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:19:46 +0200 Subject: [PATCH 030/378] UnaryPlusHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/UnaryPlusHandler.php | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index c3f38936c05..994f1f2500b 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -9,27 +9,27 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class UnaryPlusHandler implements TypeResolvingExprHandler +final class UnaryPlusHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +51,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $exprResult->getTypeForScope($scope); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From e8ec1e73900353fded79b148d229f4de5fdb2321 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:24:31 +0200 Subject: [PATCH 031/378] ConstFetchHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExprHandler/ConstFetchHandler.php | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 6f1518f163c..02965694035 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -11,12 +11,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Constant\ConstantBooleanType; @@ -26,15 +24,16 @@ use function strtolower; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ConstFetchHandler implements TypeResolvingExprHandler +final class ConstFetchHandler implements ExprHandler { public function __construct( private ConstantResolver $constantResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -56,51 +55,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $constName = (string) $expr->name; - $loweredConstName = strtolower($constName); - if ($loweredConstName === 'true') { - return new ConstantBooleanType(true); - } elseif ($loweredConstName === 'false') { - return new ConstantBooleanType(false); - } elseif ($loweredConstName === 'null') { - return new NullType(); - } + typeCallback: function (MutatingScope $scope) use ($expr): Type { + $constName = (string) $expr->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } - $namespacedName = null; - if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { - $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); - } - $globalName = new FullyQualified($expr->name->toString()); + $namespacedName = null; + if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { + $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); + } + $globalName = new FullyQualified($expr->name->toString()); - foreach ([$namespacedName, $globalName] as $name) { - if ($name === null) { - continue; - } - $constFetch = new ConstFetch($name); - if ($scope->hasExpressionType($constFetch)->yes()) { - return $this->constantResolver->resolveConstantType( - $name->toString(), - $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), - ); - } - } + foreach ([$namespacedName, $globalName] as $name) { + if ($name === null) { + continue; + } + $constFetch = new ConstFetch($name); + if ($scope->hasExpressionType($constFetch)->yes()) { + return $this->constantResolver->resolveConstantType( + $name->toString(), + $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), + ); + } + } - $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); - if ($constantType !== null) { - return $constantType; - } + $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); + if ($constantType !== null) { + return $constantType; + } - return new ErrorType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return new ErrorType(); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From cc283b848d56999fd7fd66a1cf85d3d30f7e524b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:27:32 +0200 Subject: [PATCH 032/378] PrintHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/PrintHandler.php | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 2b21db38ec4..37172802e83 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,14 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Constant\ConstantIntegerType; @@ -24,15 +22,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PrintHandler implements TypeResolvingExprHandler +final class PrintHandler implements ExprHandler { public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,11 +41,6 @@ public function supports(Expr $expr): bool return $expr instanceof Print_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new ConstantIntegerType(1); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -68,12 +62,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + typeCallback: static fn (MutatingScope $scope): Type => new ConstantIntegerType(1), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 33a79bdb5e6b5842cb4ba4d640121d45c3d96bff Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:27:32 +0200 Subject: [PATCH 033/378] ThrowHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/ThrowHandler.php | 27 +++++++++-------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 8231e9b6726..22bd0e1dc6e 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -9,13 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NonAcceptingNeverType; @@ -23,13 +21,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ThrowHandler implements TypeResolvingExprHandler +final class ThrowHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -50,17 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static fn (MutatingScope $scope): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new NonAcceptingNeverType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 4977fb0d849b2ea6e48fa046c2657dcc1ca2fa74 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:27:32 +0200 Subject: [PATCH 034/378] ExitHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/ExitHandler.php | 27 +++++++++--------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index c459f38dc11..eff4504ddf3 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -9,13 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NonAcceptingNeverType; @@ -23,13 +21,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ExitHandler implements TypeResolvingExprHandler +final class ExitHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -65,17 +66,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn (MutatingScope $scope): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new NonAcceptingNeverType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 156519f26ebe0588dd2e38ae61fe7cf74ea91796 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:27:32 +0200 Subject: [PATCH 035/378] EvalHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/EvalHandler.php | 27 +++++++++--------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index a381daf1c25..3b992be124a 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -9,14 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\MixedType; @@ -24,13 +22,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class EvalHandler implements TypeResolvingExprHandler +final class EvalHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -39,11 +40,6 @@ public function supports(Expr $expr): bool return $expr instanceof Eval_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new MixedType(); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -58,12 +54,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 66f78230ba05f85540fc2f687b97b7e73b36a469 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:27:32 +0200 Subject: [PATCH 036/378] IncludeHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/IncludeHandler.php | 27 ++++++++------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 7c8de06849c..2e802104098 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -9,14 +9,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\MixedType; @@ -25,13 +23,16 @@ use function in_array; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class IncludeHandler implements TypeResolvingExprHandler +final class IncludeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -40,11 +41,6 @@ public function supports(Expr $expr): bool return $expr instanceof Include_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new MixedType(); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -60,12 +56,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 1741a378d4d2a0bd3949699c3dde4a2da17f3ae0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:29:54 +0200 Subject: [PATCH 037/378] YieldFromHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/YieldFromHandler.php | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index ca086700866..1de51432932 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -10,14 +10,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; @@ -26,13 +24,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class YieldFromHandler implements TypeResolvingExprHandler +final class YieldFromHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -41,17 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof YieldFrom; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $yieldFromType = $scope->getType($expr->expr); - $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); - if ($generatorReturnType instanceof ErrorType) { - return new MixedType(); - } - - return $generatorReturnType; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -66,12 +56,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), - ); - } + typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { + $yieldFromType = $exprResult->getTypeForScope($scope); + $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); + if ($generatorReturnType instanceof ErrorType) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $generatorReturnType; + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From dbff89da7dd4c7b5a186746dbad29a7b3bf05148 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:29:54 +0200 Subject: [PATCH 038/378] ClassConstFetchHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExprHandler/ClassConstFetchHandler.php | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index c00160c7c50..b0f2a1d48fe 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -10,12 +10,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -24,15 +22,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ClassConstFetchHandler implements TypeResolvingExprHandler +final class ClassConstFetchHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,20 +41,6 @@ public function supports(Expr $expr): bool return $expr instanceof ClassConstFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if (!$expr->name instanceof Identifier) { - return new MixedType(); - } - - return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( - $expr->class, - $expr->name->name, - $scope->isInClass() ? $scope->getClassReflection() : null, - static fn (Expr $e): Type => $scope->getType($e), - ); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -64,6 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -94,12 +80,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (MutatingScope $scope) use ($expr, $classResult): Type { + if (!$expr->name instanceof Identifier) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( + $expr->class, + $expr->name->name, + $scope->isInClass() ? $scope->getClassReflection() : null, + // getClassConstFetchTypeByReflection only invokes this for $expr->class + // when it is an Expr, which is exactly when $classResult exists + static fn (Expr $e): Type => $classResult !== null && $e === $expr->class + ? $classResult->getTypeForScope($scope) + : $scope->getType($e), + ); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From 256890cf53217e24229b5b0cad848c996cbfad45 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 14:31:32 +0200 Subject: [PATCH 039/378] InterpolatedStringHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExprHandler/InterpolatedStringHandler.php | 57 +++++++++---------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 75547f573bb..cf7975d713f 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,31 +10,31 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use function array_merge; +use function spl_object_id; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class InterpolatedStringHandler implements TypeResolvingExprHandler +final class InterpolatedStringHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,11 +51,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + /** @var array $partResults */ + $partResults = []; foreach ($expr->parts as $part) { if (!$part instanceof Expr) { continue; } $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $partResults[spl_object_id($part)] = $partResult; $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); @@ -76,32 +79,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (MutatingScope $scope) use ($expr, $partResults): Type { + $resultType = null; + foreach ($expr->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $partType = new ConstantStringType($part->value); + } else { + $partType = $partResults[spl_object_id($part)]->getTypeForScope($scope)->toString(); + } + if ($resultType === null) { + $resultType = $partType; + continue; + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $resultType = null; - foreach ($expr->parts as $part) { - if ($part instanceof InterpolatedStringPart) { - $partType = new ConstantStringType($part->value); - } else { - $partType = $scope->getType($part)->toString(); - } - if ($resultType === null) { - $resultType = $partType; - continue; - } + $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); + } - $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); - } - - return $resultType ?? new ConstantStringType(''); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $resultType ?? new ConstantStringType(''); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From d17dd5569cf21ce7f30b1866745bf82ec9483547 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:07:07 +0200 Subject: [PATCH 040/378] CloneHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/CloneHandler.php | 31 ++++++++++------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index caddb7d0889..0492de78402 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -9,13 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\Traverser\CloneTypeTraverser; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ObjectWithoutClassType; @@ -24,13 +22,16 @@ use PHPStan\Type\TypeTraverser; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class CloneHandler implements TypeResolvingExprHandler +final class CloneHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,18 +52,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { + $cloneType = TypeCombinator::intersect($exprResult->getTypeForScope($scope), new ObjectWithoutClassType()); + return TypeTraverser::map($cloneType, new CloneTypeTraverser()); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $cloneType = TypeCombinator::intersect($scope->getType($expr->expr), new ObjectWithoutClassType()); - return TypeTraverser::map($cloneType, new CloneTypeTraverser()); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From c42e63747920f0aebe2611f130f5d652ac8b526e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:07:07 +0200 Subject: [PATCH 041/378] YieldHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/YieldHandler.php | 51 ++++++++++------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index dd8a5478fc0..6ec1d13fc57 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -10,14 +10,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; @@ -26,13 +24,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class YieldHandler implements TypeResolvingExprHandler +final class YieldHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -41,22 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof Yield_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $functionReflection = $scope->getFunction(); - if ($functionReflection === null) { - return new MixedType(); - } - - $returnType = $functionReflection->getReturnType(); - $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); - if ($generatorSendType instanceof ErrorType) { - return new MixedType(); - } - - return $generatorSendType; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -96,12 +81,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: static function (MutatingScope $scope): Type { + $functionReflection = $scope->getFunction(); + if ($functionReflection === null) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + $returnType = $functionReflection->getReturnType(); + $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); + if ($generatorSendType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorSendType; + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From ef2fe825eb46e25dc6f3563cd8b271012e818f02 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:07:07 +0200 Subject: [PATCH 042/378] AlwaysRememberedExprHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Virtual/AlwaysRememberedExprHandler.php | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index d4700f6e048..350584cd091 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -8,25 +8,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class AlwaysRememberedExprHandler implements TypeResolvingExprHandler +final class AlwaysRememberedExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -58,17 +59,9 @@ public function processExpr( isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), + typeCallback: static fn (MutatingScope $scope): Type => $scope->nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From ffabe5ebf3e462bf68fbbefef50d76f6ca082d70 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:15:39 +0200 Subject: [PATCH 043/378] NativeTypeExprHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Virtual/NativeTypeExprHandler.php | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 08715ad31cc..6ef2fa033f6 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -8,25 +8,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class NativeTypeExprHandler implements TypeResolvingExprHandler +final class NativeTypeExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -48,20 +49,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $scope): Type => $scope->nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($scope->nativeTypesPromoted) { - return $expr->getNativeType(); - } - return $expr->getPhpDocType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From bd826dd0535abd10cc3f4bf0f275c01db1599896 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:18:50 +0200 Subject: [PATCH 044/378] FunctionCallableNodeHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Virtual/FunctionCallableNodeHandler.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index b27515f40fd..ae01f6be2b0 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -8,12 +8,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\FunctionCallableNode; @@ -21,13 +19,16 @@ use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class FunctionCallableNodeHandler implements TypeResolvingExprHandler +final class FunctionCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -60,19 +61,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // in practice the type of the first-class callable is resolved + // by FirstClassCallableFuncCallHandler + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableFuncCallHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From dc08adb159ced2e56de582271594c721bb07f3be Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:18:50 +0200 Subject: [PATCH 045/378] MethodCallableNodeHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Virtual/MethodCallableNodeHandler.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 25ca81bbcd0..15aa475bbc9 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -8,12 +8,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\MethodCallableNode; @@ -22,13 +20,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class MethodCallableNodeHandler implements TypeResolvingExprHandler +final class MethodCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -63,19 +64,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // in practice the type of the first-class callable is resolved + // by FirstClassCallableMethodCallHandler + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableMethodCallHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 005e78d33c5198e9749a5261a7d39ed2c8ec8f08 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:18:50 +0200 Subject: [PATCH 046/378] StaticMethodCallableNodeHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../StaticMethodCallableNodeHandler.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 8f9570ee5ec..59a5a808985 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -8,12 +8,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\StaticMethodCallableNode; @@ -22,13 +20,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class StaticMethodCallableNodeHandler implements TypeResolvingExprHandler +final class StaticMethodCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -69,19 +70,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // in practice the type of the first-class callable is resolved + // by FirstClassCallableStaticCallHandler + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableStaticCallHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 92ed8ee53908535e3c5b188ed17ed1e2c486d272 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 15:18:50 +0200 Subject: [PATCH 047/378] InstantiationCallableNodeHandler is no longer TypeResolvingExprHandler Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InstantiationCallableNodeHandler.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 304aedb86ad..9fd33f592e6 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -8,12 +8,10 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\InstantiationCallableNode; @@ -21,13 +19,16 @@ use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class InstantiationCallableNodeHandler implements TypeResolvingExprHandler +final class InstantiationCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -60,19 +61,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // in practice the type of the first-class callable is resolved + // by FirstClassCallableNewHandler + typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableNewHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 8f8301275fa9c127c74565193b67adb668a3a139 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 16 Jun 2026 18:19:55 +0200 Subject: [PATCH 048/378] TypeExprHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/Virtual/TypeExprHandler.php | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 32208c6a569..1efa5f6db19 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -8,25 +8,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class TypeExprHandler implements TypeResolvingExprHandler +final class TypeExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -48,17 +49,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $scope): Type => $expr->getExprType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $expr->getExprType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 5b09306bfddd67264b3145da970ce07805b275d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 12:03:00 +0200 Subject: [PATCH 049/378] Fix failure of forwarding ExpressionResult --- src/Analyser/ExprHandler/AssignHandler.php | 23 ++++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 7feac501519..245e6ae4e29 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -345,7 +345,16 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $scope = $scope->exitExpressionAssign($expr->expr); } - return $this->expressionResultFactory->create($scope, $beforeScope, $expr->expr, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr->expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: static fn ($scope) => $result->getTypeForScope($scope), + ); }, true, ); @@ -558,18 +567,6 @@ public function processAssignVar( ): ExpressionResult { $beforeScope = $scope; - $nodeScopeResolver->storeExpressionResult($storage, $var, $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $var, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - // VariableHandler no longer implements TypeResolvingExprHandler - - // type questions about the target node are answered from this result - typeCallback: $var instanceof Variable ? VariableHandler::createTypeCallback($var) : null, - )); $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; From c9ebf8f747431397bb5da600c5289e013d0a15e3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:07:43 +0200 Subject: [PATCH 050/378] ErrorSuppressHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/ErrorSuppressHandler.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 25bfa07bd36..3c847d9fe0c 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -9,24 +9,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ErrorSuppressHandler implements TypeResolvingExprHandler +final class ErrorSuppressHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -37,29 +39,20 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( $exprResult->getScope(), - beforeScope: $scope, + beforeScope: $beforeScope, expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), - falseyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + typeCallback: static fn (MutatingScope $s): Type => $exprResult->getTypeForScope($s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context)->setRootExpr($expr), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyTypesInCondition($scope, $expr->expr, $context)->setRootExpr($expr); - } - } From 6a3a1bca3c9e1106848e411916ac6fb9637553e7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:07:43 +0200 Subject: [PATCH 051/378] CastHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/CastHandler.php | 84 +++++++++++++----------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index af928d21ed1..ab4f122a54b 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -15,28 +15,29 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class CastHandler implements TypeResolvingExprHandler +final class CastHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -60,45 +61,50 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: function (MutatingScope $s) use ($expr, $exprResult): Type { + if ($expr instanceof Cast\Unset_) { + return new NullType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr instanceof Cast\Unset_) { - return new NullType(); - } + return $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $exprResult->getTypeForScope($s); + } - return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } + throw new ShouldNotHappenException(); + }); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($expr instanceof Cast\Bool_) { + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $s, + new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))), + null, + $context, + )->setRootExpr($expr); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr instanceof Cast\Bool_) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))), - $context, - )->setRootExpr($expr); - } - - if ($expr instanceof Cast\Int_) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new Int_(0)), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof Cast\Int_) { + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $s, + new NotEqual($expr->expr, new Int_(0)), + null, + $context, + )->setRootExpr($expr); + } - if ($expr instanceof Cast\Double) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new Float_(0.0)), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof Cast\Double) { + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $s, + new NotEqual($expr->expr, new Float_(0.0)), + null, + $context, + )->setRootExpr($expr); + } - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + }, + ); } } From 802724ab44765e9563e7b6c0333c7b41d1fd6fb2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:07:43 +0200 Subject: [PATCH 052/378] CastStringHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/CastStringHandler.php | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index daa2c148be6..6f3d36f59c9 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -11,30 +11,31 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class CastStringHandler implements TypeResolvingExprHandler +final class CastStringHandler implements ExprHandler { public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -65,21 +66,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } + typeCallback: fn (MutatingScope $s): Type => $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $exprResult->getTypeForScope($s); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new String_('')), - $context, - )->setRootExpr($expr); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $s, + new NotEqual($expr->expr, new String_('')), + null, + $context, + )->setRootExpr($expr), + ); } } From a3dc63875e39b08cc1650c17f6395a6df253c18a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:21:57 +0200 Subject: [PATCH 053/378] PostIncHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/PostIncHandler.php | 27 +++++++++------------ 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index a04b0cfea75..a16eb22282f 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -10,24 +10,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PostIncHandler implements TypeResolvingExprHandler +final class PostIncHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -55,17 +57,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + // post-increment evaluates to the variable's pre-mutation value + typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->var); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 02b8a5c6694924f58744a6a0c28f4e6bbc9054d9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:21:57 +0200 Subject: [PATCH 054/378] PostDecHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/PostDecHandler.php | 27 +++++++++------------ 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 0cff682498c..d3fafe25b50 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -10,24 +10,26 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PostDecHandler implements TypeResolvingExprHandler +final class PostDecHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -55,17 +57,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + // post-decrement evaluates to the variable's pre-mutation value + typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->var); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From a1d9472074597e7054409cfd8a8c9632009fb044 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 14:38:00 +0200 Subject: [PATCH 055/378] PipeHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/PipeHandler.php | 43 ++++++------------------ 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 45a5e23322d..6f27e615d52 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -13,12 +13,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -27,13 +26,16 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PipeHandler implements TypeResolvingExprHandler +final class PipeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -42,27 +44,6 @@ public function supports(Expr $expr): bool return $expr instanceof Pipe; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new FuncCall($expr->right->name, [ - new Arg($expr->left), - ])); - } elseif ($expr->right instanceof MethodCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new MethodCall($expr->right->var, $expr->right->name, [ - new Arg($expr->left), - ])); - } elseif ($expr->right instanceof StaticCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new StaticCall($expr->right->class, $expr->right->name, [ - new Arg($expr->left), - ])); - } - - return $scope->getType(new FuncCall($expr->right, [ - new Arg($expr->left), - ])); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $rightAttributes = array_merge($expr->right->getAttributes(), ['virtualPipeOperatorCall' => true]); @@ -116,12 +97,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), + // the pipe evaluates to its rewritten call - read that child's result + typeCallback: static fn (MutatingScope $s): Type => $callResult->getTypeForScope($s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 5a3fd5a6feb65d2292236ba9b466208a3991503b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 15:29:41 +0200 Subject: [PATCH 056/378] PreIncHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/PreIncHandler.php | 129 +++++++++++++-------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 73fd74c7dc5..5391bffb825 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,16 +10,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -36,13 +37,17 @@ use function str_increment; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PreIncHandler implements TypeResolvingExprHandler +final class PreIncHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,58 +56,83 @@ public function supports(Expr $expr): bool return $expr instanceof PreInc; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $varType = $scope->getType($expr->var); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = '1'; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_increment($varValue); - } catch (ValueError) { - return new NeverType(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + + $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { + $varType = $varResult->getTypeForScope($s); + $varScalars = $varType->getConstantScalarValues(); + + if (count($varScalars) > 0) { + $newTypes = []; + + foreach ($varScalars as $varValue) { + if ($varValue === '') { + $varValue = '1'; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_increment($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (!is_bool($varValue)) { + ++$varValue; } - } elseif (!is_bool($varValue)) { - ++$varValue; + + $newTypes[] = $s->getTypeFromValue($varValue); + } + return TypeCombinator::union(...$newTypes); + } elseif ($varType->isString()->yes()) { + if ($varType->isLiteralString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); } - $newTypes[] = $scope->getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } + if ($varType->isNumericString()->yes()) { + return new BenevolentUnionType([ + new IntegerType(), + new FloatType(), + ]); + } - if ($varType->isNumericString()->yes()) { return new BenevolentUnionType([ + new StringType(), new IntegerType(), new FloatType(), ]); } - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } + $one = new Int_(1); + return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { + if ($e === $expr->var) { + return $varResult->getTypeForScope($s); + } + if ($e === $one) { + return new ConstantIntegerType(1); + } - return $scope->getType(new Plus($expr->var, new Int_(1))); - } + throw new ShouldNotHappenException(); + }); + }; + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // processVirtualAssign asks getType($expr) for the value to assign; store + // this result first so that resolves from the typeCallback below rather + // than re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + isAlwaysTerminating: $varResult->isAlwaysTerminating(), + throwPoints: $varResult->getThrowPoints(), + impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( @@ -119,12 +149,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From b352f0b94fc5d8eb4a614b93c4671defe5f56b15 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 15:29:41 +0200 Subject: [PATCH 057/378] PreDecHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/PreDecHandler.php | 129 +++++++++++++-------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 221376fd209..b21eb79a145 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Minus; use PhpParser\Node\Expr\PreDec; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,16 +10,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -35,13 +36,17 @@ use function str_decrement; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PreDecHandler implements TypeResolvingExprHandler +final class PreDecHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -50,58 +55,83 @@ public function supports(Expr $expr): bool return $expr instanceof PreDec; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $varType = $scope->getType($expr->var); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = -1; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_decrement($varValue); - } catch (ValueError) { - return new NeverType(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + + $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { + $varType = $varResult->getTypeForScope($s); + $varScalars = $varType->getConstantScalarValues(); + + if (count($varScalars) > 0) { + $newTypes = []; + + foreach ($varScalars as $varValue) { + if ($varValue === '') { + $varValue = -1; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_decrement($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (is_numeric($varValue)) { + --$varValue; } - } elseif (is_numeric($varValue)) { - --$varValue; + + $newTypes[] = $s->getTypeFromValue($varValue); + } + return TypeCombinator::union(...$newTypes); + } elseif ($varType->isString()->yes()) { + if ($varType->isLiteralString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); } - $newTypes[] = $scope->getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } + if ($varType->isNumericString()->yes()) { + return new BenevolentUnionType([ + new IntegerType(), + new FloatType(), + ]); + } - if ($varType->isNumericString()->yes()) { return new BenevolentUnionType([ + new StringType(), new IntegerType(), new FloatType(), ]); } - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } + $one = new Int_(1); + return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { + if ($e === $expr->var) { + return $varResult->getTypeForScope($s); + } + if ($e === $one) { + return new ConstantIntegerType(1); + } - return $scope->getType(new Minus($expr->var, new Int_(1))); - } + throw new ShouldNotHappenException(); + }); + }; + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // processVirtualAssign asks getType($expr) for the value to assign; store + // this result first so that resolves from the typeCallback below rather + // than re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + isAlwaysTerminating: $varResult->isAlwaysTerminating(), + throwPoints: $varResult->getThrowPoints(), + impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( @@ -118,12 +148,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 8c9c133addce4040e7c243edf57d1d277eee9ca5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 15:38:26 +0200 Subject: [PATCH 058/378] AssignOpHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/AssignOpHandler.php | 151 ++++++++++--------- 1 file changed, 82 insertions(+), 69 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 2b7d0dd236e..3fc63421083 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -13,14 +13,13 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -34,10 +33,10 @@ use function sprintf; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class AssignOpHandler implements TypeResolvingExprHandler +final class AssignOpHandler implements ExprHandler { public function __construct( @@ -45,6 +44,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -57,6 +57,81 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; + + $typeCallback = function (MutatingScope $s) use ($expr): Type { + $getType = static fn (Expr $e): Type => $s->getType($e); + + if ($expr instanceof Expr\AssignOp\Coalesce) { + return $s->getType(new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes())); + } + + if ($expr instanceof Expr\AssignOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->var, $expr->expr, $getType); + } + + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + }; + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + + // processAssignVar asks getType($expr) for the value to assign; store this + // result first so it resolves from the typeCallback above rather than + // re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + $assignResult = $this->assignHandler->processAssignVar( $nodeScopeResolver, $scope, @@ -122,71 +197,9 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); - - if ($expr instanceof Expr\AssignOp\Coalesce) { - return $scope->getType(new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes())); - } - - if ($expr instanceof Expr\AssignOp\Concat) { - return $this->initializerExprTypeResolver->getConcatType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseAnd) { - return $this->initializerExprTypeResolver->getBitwiseAndType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseOr) { - return $this->initializerExprTypeResolver->getBitwiseOrType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseXor) { - return $this->initializerExprTypeResolver->getBitwiseXorType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Div) { - return $this->initializerExprTypeResolver->getDivType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Mod) { - return $this->initializerExprTypeResolver->getModType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Plus) { - return $this->initializerExprTypeResolver->getPlusType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Minus) { - return $this->initializerExprTypeResolver->getMinusType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Mul) { - return $this->initializerExprTypeResolver->getMulType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Pow) { - return $this->initializerExprTypeResolver->getPowType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\ShiftLeft) { - return $this->initializerExprTypeResolver->getShiftLeftType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\ShiftRight) { - return $this->initializerExprTypeResolver->getShiftRightType($expr->var, $expr->expr, $getType); - } - - throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From aaf27d993dd8f80370fd70642d5d21349aed81d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 15:52:19 +0200 Subject: [PATCH 059/378] AssignHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/AssignHandler.php | 260 ++++++--------------- 1 file changed, 68 insertions(+), 192 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 245e6ae4e29..6cf62e430f1 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -28,6 +28,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -36,7 +37,6 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -86,10 +86,10 @@ use function is_string; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class AssignHandler implements TypeResolvingExprHandler +final class AssignHandler implements ExprHandler { public function __construct( @@ -108,189 +108,6 @@ public function supports(Expr $expr): bool return $expr instanceof Assign || $expr instanceof AssignRef; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$expr instanceof Assign) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - if ($context->null()) { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); - $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); - } else { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); - } - - // infer $arr[$key] after $key = array_key_first/last($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - $isNonEmpty = true; - } else { - $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); - } - - if ($isNonEmpty) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), - ); - } - } - } - } - - // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && count($expr->expr->getArgs()) >= 2 - ) { - $funcName = $expr->expr->name->toLowerString(); - $arrayArg = null; - $sentinelType = null; - $isStrictArraySearch = false; - - if ($funcName === 'array_search') { - $arrayArg = $expr->expr->getArgs()[1]->value; - $sentinelType = new ConstantBooleanType(false); - $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); - } elseif ($funcName === 'array_find_key') { - $arrayArg = $expr->expr->getArgs()[0]->value; - $sentinelType = new NullType(); - } - - if ($arrayArg !== null) { - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $dimFetchType, TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); - if (!$narrowedKeyType instanceof NeverType) { - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), - ); - } - } - } - } - } - - if ($context->null()) { - // infer $arr[$key] after $key = array_rand($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $numArg = null; - $args = $expr->expr->getArgs(); - $arrayArg = $args[0]->value; - if (count($args) > 1) { - $numArg = $args[1]->value; - } - $one = new ConstantIntegerType(1); - $arrayType = $scope->getType($arrayArg); - - if ( - $arrayType->isArray()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - // infer $list[$count] after $count = count($list) - 1 - if ( - $expr->expr instanceof Expr\BinaryOp\Minus - && $expr->expr->left instanceof FuncCall - && $expr->expr->left->name instanceof Name - && !$expr->expr->left->isFirstClassCallable() - && $expr->expr->right instanceof Node\Scalar\Int_ - && $expr->expr->right->value === 1 - && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) - && count($expr->expr->left->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->left->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - if ( - $arrayType->isList()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - return $specifiedTypes; - } - - return $specifiedTypes; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -404,7 +221,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($expr) : null, + typeCallback: static fn (MutatingScope $s): Type => $assignedExprResult !== null ? $assignedExprResult->getTypeForScope($s) : $s->getType($expr->expr), + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($expr, $assignedExprResult) : null, createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); } @@ -437,15 +255,16 @@ private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assi * * @return Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ - private function createSpecifyTypesCallback(Assign $expr): Closure + private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult): Closure { - return function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + return function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $assignedExprResult): SpecifiedTypes { if ($context->null()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); + $specifiedTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s->exitFirstLevelStatements(), $expr->expr, $assignedExprResult, $context)->setRootExpr($expr); + $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); + } else { + $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); } - $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); - // infer $arr[$key] after $key = array_key_first/last($arr) if ( $expr->expr instanceof FuncCall @@ -545,6 +364,63 @@ private function createSpecifyTypesCallback(Assign $expr): Closure } } + if ($context->null()) { + // infer $arr[$key] after $key = array_rand($arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $numArg = null; + $args = $expr->expr->getArgs(); + $arrayArg = $args[0]->value; + if (count($args) > 1) { + $numArg = $args[1]->value; + } + $one = new ConstantIntegerType(1); + $arrayType = $s->getType($arrayArg); + + if ( + $arrayType->isArray()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + && ($numArg === null || $one->isSuperTypeOf($s->getType($numArg))->yes()) + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + return $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } + } + + // infer $list[$count] after $count = count($list) - 1 + if ( + $expr->expr instanceof Expr\BinaryOp\Minus + && $expr->expr->left instanceof FuncCall + && $expr->expr->left->name instanceof Name + && !$expr->expr->left->isFirstClassCallable() + && $expr->expr->right instanceof Node\Scalar\Int_ + && $expr->expr->right->value === 1 + && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) + && count($expr->expr->left->getArgs()) >= 1 + ) { + $arrayArg = $expr->expr->left->getArgs()[0]->value; + $arrayType = $s->getType($arrayArg); + if ( + $arrayType->isList()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + return $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } + } + } + return $specifiedTypes; }; } From 4ec4536de0baa342832fb5eb05366e98dd9267b9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 16:53:08 +0200 Subject: [PATCH 060/378] Track containsNullsafe on ExpressionResult and propagate it through fetch/call chains --- src/Analyser/ExprHandler/ArrayDimFetchHandler.php | 2 ++ src/Analyser/ExprHandler/MethodCallHandler.php | 1 + .../ExprHandler/NullsafeMethodCallHandler.php | 1 + .../ExprHandler/NullsafePropertyFetchHandler.php | 1 + src/Analyser/ExprHandler/PropertyFetchHandler.php | 1 + src/Analyser/ExprHandler/StaticCallHandler.php | 3 +++ .../ExprHandler/StaticPropertyFetchHandler.php | 3 +++ src/Analyser/ExpressionResult.php | 12 ++++++++++++ src/Analyser/ExpressionResultFactory.php | 1 + 9 files changed, 25 insertions(+) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 14dec18a003..891aa7cc1c6 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -94,6 +94,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + containsNullsafe: $varResult->containsNullsafe(), ); } @@ -123,6 +124,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, + containsNullsafe: $varResult->containsNullsafe(), ); } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 08f36effeec..7656e309bfa 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -210,6 +210,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + containsNullsafe: $varResult->containsNullsafe(), ); $calledOnType = $originalScope->getType($expr->var); diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index b5db844ed26..9f8ec0fce9f 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -126,6 +126,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + containsNullsafe: true, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 017a57b723d..26bd5d41224 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -105,6 +105,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + containsNullsafe: true, ); } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 43c0dab3d19..e0b42dd2a5a 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -92,6 +92,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + containsNullsafe: $varResult->containsNullsafe(), ); } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index a868cdce692..3c06b141211 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -86,6 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $containsNullsafe = false; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -94,6 +95,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); + $containsNullsafe = $classResult->containsNullsafe(); } $parametersAcceptor = null; @@ -298,6 +300,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + containsNullsafe: $containsNullsafe, ); } diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index b8fc583e450..6fa11227f8f 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -66,6 +66,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), ]; $isAlwaysTerminating = false; + $containsNullsafe = false; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -73,6 +74,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $classResult->getImpurePoints(); $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); + $containsNullsafe = $classResult->containsNullsafe(); } if (!$expr->name instanceof VarLikeIdentifier) { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -91,6 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + containsNullsafe: $containsNullsafe, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 43de235c169..736af44d8dd 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -53,6 +53,7 @@ public function __construct( private bool $isAlwaysTerminating, private array $throwPoints, private array $impurePoints, + private bool $containsNullsafe = false, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, @@ -82,6 +83,17 @@ public function hasYield(): bool return $this->hasYield; } + /** + * Whether this expression's chain contains a nullsafe operator (?->). A + * fetch/call on a receiver whose chain short-circuits propagates null, + * which a plain nullable receiver (e.g. a nullable variable) does not - + * this flag is what tells them apart. + */ + public function containsNullsafe(): bool + { + return $this->containsNullsafe; + } + /** * @return InternalThrowPoint[] */ diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 83172cd7eb6..355bd4bf615 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -25,6 +25,7 @@ public function create( bool $isAlwaysTerminating, array $throwPoints, array $impurePoints, + bool $containsNullsafe = false, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, From 3b0db040d302b3ca9fb7a4c2ddea81b9b2157519 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 17:02:56 +0200 Subject: [PATCH 061/378] PropertyFetchHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/PropertyFetchHandler.php | 105 +++++++++--------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index e0b42dd2a5a..bc763121299 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -11,14 +11,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; @@ -32,16 +30,17 @@ use function count; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class PropertyFetchHandler implements TypeResolvingExprHandler +final class PropertyFetchHandler implements ExprHandler { public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -61,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $varResult->getImpurePoints(); $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $scope = $varResult->getScope(); + $nameResult = null; if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); $propertyHolderType = $scopeBeforeVar->getType($expr->var); @@ -93,52 +93,56 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); - if ($propertyReflection === null) { - return new ErrorType(); + typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult): Type { + // a fetch on a nullsafe chain whose receiver is currently nullable + // short-circuits to null - the receiver result carries whether the + // chain contains a ?-> (a plain nullable receiver does not propagate) + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($varResult->getTypeForScope($s)) + ? TypeCombinator::addNull($type) + : $type; + + if ($expr->name instanceof Identifier) { + if ($s->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + return $shortCircuit($propertyReflection->getNativeType()); + } + + $returnType = $this->propertyFetchType( + $s, + $varResult->getTypeForScope($s), + $expr->name->name, + $expr, + ); + if ($returnType === null) { + $returnType = new ErrorType(); + } + + return $shortCircuit($returnType); } - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $s->getType($expr->name); + if (count($nameType->getConstantStrings()) > 0) { + return TypeCombinator::union( + ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $s + ->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))) + ->getType( + new PropertyFetch($expr->var, new Identifier($constantString->getValue())), + ), $nameType->getConstantStrings()), + ); } - $nativeType = $propertyReflection->getNativeType(); - - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $nativeType); - } - - $returnType = $this->propertyFetchType( - $scope, - $scope->getType($expr->var), - $expr->name->name, - $expr, - ); - if ($returnType === null) { - $returnType = new ErrorType(); - } - - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); - } - - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))) - ->getType( - new PropertyFetch($expr->var, new Identifier($constantString->getValue())), - ), $nameType->getConstantStrings()), - ); - } - - return new MixedType(); + return new MixedType(); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch $propertyFetch): ?Type @@ -155,9 +159,4 @@ private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, st return $propertyReflection->getReadableType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 4c08046e196200f11c36b67b7ce31e454809cb06 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 17:23:40 +0200 Subject: [PATCH 062/378] StaticPropertyFetchHandler is no longer TypeResolvingExprHandler --- .../StaticPropertyFetchHandler.php | 122 ++++++++---------- 1 file changed, 55 insertions(+), 67 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 6fa11227f8f..5f269a73ac1 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -13,14 +13,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Rules\Properties\PropertyReflectionFinder; @@ -33,15 +31,16 @@ use function count; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class StaticPropertyFetchHandler implements TypeResolvingExprHandler +final class StaticPropertyFetchHandler implements ExprHandler { public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -66,7 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), ]; $isAlwaysTerminating = false; - $containsNullsafe = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -74,8 +73,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $classResult->getImpurePoints(); $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); - $containsNullsafe = $classResult->containsNullsafe(); } + $nameResult = null; if (!$expr->name instanceof VarLikeIdentifier) { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $nameResult->hasYield(); @@ -93,64 +92,58 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - containsNullsafe: $containsNullsafe, - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof VarLikeIdentifier) { - if ($scope->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), + typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult): Type { + $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && TypeCombinator::containsNull($classResult->getTypeForScope($s)) + ? TypeCombinator::addNull($type) + : $type; + + if ($expr->name instanceof VarLikeIdentifier) { + if ($s->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s); + if ($propertyReflection === null) { + return new ErrorType(); + } + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + return $shortCircuit($propertyReflection->getNativeType()); + } + + if ($expr->class instanceof Name) { + $staticPropertyFetchedOnType = $s->resolveTypeByName($expr->class); + } else { + $classType = $classResult !== null ? $classResult->getTypeForScope($s) : $s->getType($expr->class); + $staticPropertyFetchedOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); + } + + $fetchType = $this->propertyFetchType( + $s, + $staticPropertyFetchedOnType, + $expr->name->toString(), + $expr, + ); + if ($fetchType === null) { + $fetchType = new ErrorType(); + } + + return $shortCircuit($fetchType); } - $nativeType = $propertyReflection->getNativeType(); - - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $nativeType); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $s->getType($expr->name); + if (count($nameType->getConstantStrings()) > 0) { + return TypeCombinator::union( + ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $s + ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) + ->getType(new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue()))), $nameType->getConstantStrings()), + ); } - return $nativeType; - } - - if ($expr->class instanceof Name) { - $staticPropertyFetchedOnType = $scope->resolveTypeByName($expr->class); - } else { - $staticPropertyFetchedOnType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); - } - - $fetchType = $this->propertyFetchType( - $scope, - $staticPropertyFetchedOnType, - $expr->name->toString(), - $expr, - ); - if ($fetchType === null) { - $fetchType = new ErrorType(); - } - - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $fetchType); - } - - return $fetchType; - } - - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue()))), $nameType->getConstantStrings()), - ); - } - - return new MixedType(); + return new MixedType(); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, StaticPropertyFetch $propertyFetch): ?Type @@ -167,9 +160,4 @@ private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, st return $propertyReflection->getReadableType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From ff57d2df4b2bd8da924ed54b7ae3ddeb1c0d769f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 17:32:05 +0200 Subject: [PATCH 063/378] Store ArrayDimFetch assign-target results with a typeCallback --- src/Analyser/ExprHandler/AssignHandler.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 6cf62e430f1..1285155fd1e 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -637,6 +637,7 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (): Type => new NeverType(), )); } else { @@ -654,6 +655,7 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType($dimFetch->var)->getOffsetValueType($s->getType($dimExpr)), )); $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $result->hasYield(); From b4e815b7c12e830197ded7a37cbe9890f7b09188 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 17:32:05 +0200 Subject: [PATCH 064/378] ArrayDimFetchHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/ArrayDimFetchHandler.php | 82 ++++++++----------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 891aa7cc1c6..d4f95b7abb9 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -13,30 +13,32 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ArrayDimFetchHandler implements TypeResolvingExprHandler +final class ArrayDimFetchHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -45,40 +47,6 @@ public function supports(Expr $expr): bool return $expr instanceof ArrayDimFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->dim === null) { - return new NeverType(); - } - - $offsetAccessibleType = $scope->getType($expr->var); - if ( - !$offsetAccessibleType->isArray()->yes() - && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() - ) { - return NullsafeShortCircuitingHelper::getType( - $scope, - $expr->var, - $scope->getType( - new MethodCall( - $expr->var, - new Identifier('offsetGet'), - [ - new Arg($expr->dim), - ], - ), - ), - ); - } - - $offsetType = $scope->getType($expr->dim); - return NullsafeShortCircuitingHelper::getType( - $scope, - $expr->var, - $offsetAccessibleType->getOffsetValueType($offsetType), - ); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -95,6 +63,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), containsNullsafe: $varResult->containsNullsafe(), + // `$arr[]` only appears as an assignment target; reading it is a NeverType + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -125,12 +96,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), - ); - } + typeCallback: function (MutatingScope $s) use ($expr, $varResult, $dimResult): Type { + $offsetAccessibleType = $varResult->getTypeForScope($s); + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) + ? TypeCombinator::addNull($type) + : $type; - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + if ( + !$offsetAccessibleType->isArray()->yes() + && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() + ) { + return $shortCircuit($s->getType( + new MethodCall( + $expr->var, + new Identifier('offsetGet'), + [ + new Arg($expr->dim), + ], + ), + )); + } + + return $shortCircuit($offsetAccessibleType->getOffsetValueType($dimResult->getTypeForScope($s))); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From 60fe399c33800d48c86c119fb86b2b2cb922d359 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 18:23:04 +0200 Subject: [PATCH 065/378] Introduce IssetabilityDescriptor and fold it in MutatingScope::issetCheck --- .../ExprHandler/ArrayDimFetchHandler.php | 2 + .../ExprHandler/PropertyFetchHandler.php | 3 + .../StaticPropertyFetchHandler.php | 3 + src/Analyser/ExprHandler/VariableHandler.php | 2 + src/Analyser/ExpressionResult.php | 11 + src/Analyser/ExpressionResultFactory.php | 1 + src/Analyser/IssetabilityDescriptor.php | 228 ++++++++++++++++++ src/Analyser/MutatingScope.php | 11 + 8 files changed, 261 insertions(+) create mode 100644 src/Analyser/IssetabilityDescriptor.php diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index d4f95b7abb9..efcb21cce2d 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; @@ -96,6 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), + issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), typeCallback: function (MutatingScope $s) use ($expr, $varResult, $dimResult): Type { $offsetAccessibleType = $varResult->getTypeForScope($s); $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index bc763121299..911f9e7624e 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -14,12 +14,14 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; +use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; +use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; @@ -93,6 +95,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), + issetabilityDescriptor: IssetabilityDescriptor::property($varResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult): Type { // a fetch on a nullsafe chain whose receiver is currently nullable // short-circuits to null - the receiver result carries whether the diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 5f269a73ac1..5170c3e258c 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -16,11 +16,13 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; @@ -93,6 +95,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), + issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult): Type { $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && TypeCombinator::containsNull($classResult->getTypeForScope($s)) ? TypeCombinator::addNull($type) diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 03692785a53..07ab8493bf3 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -122,6 +123,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, typeCallback: self::createTypeCallback($expr, $nameResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 736af44d8dd..d1e80322384 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -54,6 +54,7 @@ public function __construct( private array $throwPoints, private array $impurePoints, private bool $containsNullsafe = false, + private ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, @@ -94,6 +95,16 @@ public function containsNullsafe(): bool return $this->containsNullsafe; } + /** + * The isset/empty/?? chain descriptor for this expression, or null when the + * expression is not a variable / array dim fetch / property fetch chain link + * (in which case isset() falls back to the leaf type check). + */ + public function getIssetabilityDescriptor(): ?IssetabilityDescriptor + { + return $this->issetabilityDescriptor; + } + /** * @return InternalThrowPoint[] */ diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 355bd4bf615..b996c62ce30 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -26,6 +26,7 @@ public function create( array $throwPoints, array $impurePoints, bool $containsNullsafe = false, + ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ?callable $typeCallback = null, diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php new file mode 100644 index 00000000000..c4d29259025 --- /dev/null +++ b/src/Analyser/IssetabilityDescriptor.php @@ -0,0 +1,228 @@ +kind === self::KIND_VARIABLE) { + $variableName = $this->variableName; + if ($variableName === null) { + throw new ShouldNotHappenException(); + } + + $hasVariable = $scope->hasVariableType($variableName); + if ($hasVariable->maybe()) { + return null; + } + + if ($result === null) { + if ($hasVariable->yes()) { + if ($variableName === '_SESSION') { + return null; + } + + return $typeCallback($scope->getVariableType($variableName)); + } + + return false; + } + + return $result; + } + + if ($this->kind === self::KIND_OFFSET) { + $varResult = $this->varResult; + $dimResult = $this->dimResult; + if ($varResult === null || $dimResult === null) { + throw new ShouldNotHappenException(); + } + + $type = $varResult->getTypeForScope($scope); + if (!$type->isOffsetAccessible()->yes()) { + return $result ?? $this->checkUndefinedInner($varResult, $scope); + } + + $dimType = $dimResult->getTypeForScope($scope); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if ($hasOffsetValue->no()) { + return false; + } + + // If offset cannot be null, store this error message and see if one of the earlier offsets is. + // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. + if ($hasOffsetValue->yes()) { + $result = $typeCallback($type->getOffsetValueType($dimType)); + + if ($result !== null) { + return $this->checkInner($varResult, $scope, $typeCallback, $result); + } + } + + // Has offset, it is nullable + return null; + } + + $reflectionResolver = $this->reflectionResolver; + $propertyFetch = $this->propertyFetch; + if ($reflectionResolver === null || $propertyFetch === null) { + throw new ShouldNotHappenException(); + } + $innerResult = $this->innerResult; + + $propertyReflection = $reflectionResolver($scope); + if ($propertyReflection === null) { + return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; + } + + if (!$propertyReflection->isNative()) { + return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; + } + + if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { + if (!$scope->hasExpressionType($propertyFetch)->yes()) { + $nativeReflection = $propertyReflection->getNativeReflection(); + if ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { + return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; + } + } + } + + if ($result !== null) { + return $innerResult !== null ? $this->checkInner($innerResult, $scope, $typeCallback, $result) : $result; + } + + $result = $typeCallback($propertyReflection->getWritableType()); + if ($result !== null && $innerResult !== null) { + return $this->checkInner($innerResult, $scope, $typeCallback, $result); + } + + return $result; + } + + public function checkUndefined(MutatingScope $scope): ?bool + { + if ($this->kind === self::KIND_VARIABLE) { + $variableName = $this->variableName; + if ($variableName === null) { + throw new ShouldNotHappenException(); + } + + $hasVariable = $scope->hasVariableType($variableName); + if (!$hasVariable->no()) { + return null; + } + + return false; + } + + if ($this->kind === self::KIND_OFFSET) { + $varResult = $this->varResult; + $dimResult = $this->dimResult; + if ($varResult === null || $dimResult === null) { + throw new ShouldNotHappenException(); + } + + $type = $varResult->getTypeForScope($scope); + $dimType = $dimResult->getTypeForScope($scope); + $hasOffsetValue = $type->hasOffsetValueType($dimType); + if (!$type->isOffsetAccessible()->yes()) { + return $this->checkUndefinedInner($varResult, $scope); + } + + if (!$hasOffsetValue->no()) { + return $this->checkUndefinedInner($varResult, $scope); + } + + return false; + } + + $innerResult = $this->innerResult; + + return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; + } + + /** + * @param callable(Type): ?bool $typeCallback + */ + private function checkInner(ExpressionResult $inner, MutatingScope $scope, callable $typeCallback, ?bool $result): ?bool + { + $innerDescriptor = $inner->getIssetabilityDescriptor(); + if ($innerDescriptor !== null) { + return $innerDescriptor->check($scope, $typeCallback, $result); + } + + return $result ?? $typeCallback($inner->getTypeForScope($scope)); + } + + private function checkUndefinedInner(ExpressionResult $inner, MutatingScope $scope): ?bool + { + $innerDescriptor = $inner->getIssetabilityDescriptor(); + if ($innerDescriptor !== null) { + return $innerDescriptor->checkUndefined($scope); + } + + return null; + } + +} diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index dd876c51f9f..d2bd9451240 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1219,6 +1219,17 @@ public function popExpressionResultStorage(): void public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = null): ?bool { // mirrored in PHPStan\Rules\IssetCheck + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $exprResult = $storage->findExpressionResult($expr); + if ($exprResult !== null) { + $descriptor = $exprResult->getIssetabilityDescriptor(); + if ($descriptor !== null) { + return $descriptor->check($this, $typeCallback, $result); + } + } + } + if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { $hasVariable = $this->hasVariableType($expr->name); if ($hasVariable->maybe()) { From 2f88fdb81cfeccc1544ef8f77dc84352ed430693 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 18:44:48 +0200 Subject: [PATCH 066/378] Move issetCheck onto ExpressionResult with isset()/empty() convenience --- src/Analyser/ExpressionResult.php | 57 +++++++++++++++++++++++++ src/Analyser/IssetabilityDescriptor.php | 14 +----- src/Analyser/MutatingScope.php | 7 +-- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index d1e80322384..a24a0aa2ce2 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -105,6 +105,63 @@ public function getIssetabilityDescriptor(): ?IssetabilityDescriptor return $this->issetabilityDescriptor; } + /** + * Whether isset($expr) holds: folds the isset/empty/?? chain descriptor, or + * applies the leaf type check when the expression is not a chain link. + * + * @param callable(Type): ?bool $typeCallback + */ + public function issetCheck(MutatingScope $scope, callable $typeCallback, ?bool $result = null): ?bool + { + if ($this->issetabilityDescriptor !== null) { + return $this->issetabilityDescriptor->check($scope, $typeCallback, $result); + } + + return $result ?? $typeCallback($this->getTypeForScope($scope)); + } + + public function issetCheckUndefined(MutatingScope $scope): ?bool + { + return $this->issetabilityDescriptor?->checkUndefined($scope); + } + + /** Whether isset($expr) is definitely true/false (null = maybe). */ + public function isset(MutatingScope $scope): ?bool + { + return $this->issetCheck($scope, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + } + + /** + * Whether $expr is definitely set-and-non-falsey (i.e. the negation of + * empty($expr)); null = maybe. EmptyHandler negates the result. + */ + public function empty(MutatingScope $scope): ?bool + { + return $this->issetCheck($scope, static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + } + /** * @return InternalThrowPoint[] */ diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index c4d29259025..4164b6fd2a5 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -207,22 +207,12 @@ public function checkUndefined(MutatingScope $scope): ?bool */ private function checkInner(ExpressionResult $inner, MutatingScope $scope, callable $typeCallback, ?bool $result): ?bool { - $innerDescriptor = $inner->getIssetabilityDescriptor(); - if ($innerDescriptor !== null) { - return $innerDescriptor->check($scope, $typeCallback, $result); - } - - return $result ?? $typeCallback($inner->getTypeForScope($scope)); + return $inner->issetCheck($scope, $typeCallback, $result); } private function checkUndefinedInner(ExpressionResult $inner, MutatingScope $scope): ?bool { - $innerDescriptor = $inner->getIssetabilityDescriptor(); - if ($innerDescriptor !== null) { - return $innerDescriptor->checkUndefined($scope); - } - - return null; + return $inner->issetCheckUndefined($scope); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d2bd9451240..19a01057755 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1222,11 +1222,8 @@ public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = n $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage !== null) { $exprResult = $storage->findExpressionResult($expr); - if ($exprResult !== null) { - $descriptor = $exprResult->getIssetabilityDescriptor(); - if ($descriptor !== null) { - return $descriptor->check($this, $typeCallback, $result); - } + if ($exprResult !== null && $exprResult->getIssetabilityDescriptor() !== null) { + return $exprResult->issetCheck($this, $typeCallback, $result); } } From 4ba8fd2efb461d10e958774811a4f0f791326e19 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 18:48:08 +0200 Subject: [PATCH 067/378] CoalesceHandler reads issetCheck from the left result instead of the scope --- src/Analyser/ExprHandler/CoalesceHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 27c0fc4a889..0b496e8488a 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -53,7 +53,7 @@ public function supports(Expr $expr): bool */ private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, ExpressionResult $condResult, TypeSpecifierContext $context): SpecifiedTypes { - $isset = $s->issetCheck($expr->left, static fn () => true); + $isset = $condResult->issetCheck($s, static fn () => true); if ($isset !== true) { return new SpecifiedTypes(); @@ -93,7 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope): Type { $issetLeftExpr = new Expr\Isset_([$expr->left]); - $result = $s->issetCheck($expr->left, static function (Type $type): ?bool { + $result = $condResult->issetCheck($s, static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; From 2d864e12502d5b57b22fd75eddcb3d0f9065b8dd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 18:51:47 +0200 Subject: [PATCH 068/378] EmptyHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/EmptyHandler.php | 70 ++++++++--------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 1939315de1b..55f32fde61f 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -10,30 +10,29 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class EmptyHandler implements TypeResolvingExprHandler +final class EmptyHandler implements ExprHandler { public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, ) { } @@ -43,48 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof Empty_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $result = $scope->issetCheck($expr->expr, static function (Type $type): ?bool { - $isNull = $type->isNull(); - $isFalsey = $type->toBoolean()->isFalse(); - if ($isNull->maybe()) { - return null; - } - if ($isFalsey->maybe()) { - return null; - } - - if ($isNull->yes()) { - return $isFalsey->no(); - } - - return !$isFalsey->yes(); - }); - if ($result === null) { - return new BooleanType(); - } - - return new ConstantBooleanType(!$result); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($expr->expr, static fn () => true); - if ($isset === false) { - return new SpecifiedTypes(); - } - - return $typeSpecifier->specifyTypesInCondition($scope, new BooleanOr( - new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), - new Expr\BooleanNot($expr->expr), - ), $context)->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -103,6 +60,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static function (MutatingScope $s) use ($exprResult): Type { + $result = $exprResult->empty($s); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { + $isset = $exprResult->issetCheck($s, static fn () => true); + if ($isset === false) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->specifyTypesInCondition($s, new BooleanOr( + new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), + new Expr\BooleanNot($expr->expr), + ), $context)->setRootExpr($expr); + }, ); } From 33c680b85d643f90c15d118fc2820e61e71dc114 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 19:08:57 +0200 Subject: [PATCH 069/378] Match issetCheckUndefined ordering in IssetabilityDescriptor::checkUndefined --- src/Analyser/IssetabilityDescriptor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index 4164b6fd2a5..a060ef1ae81 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -184,12 +184,12 @@ public function checkUndefined(MutatingScope $scope): ?bool } $type = $varResult->getTypeForScope($scope); - $dimType = $dimResult->getTypeForScope($scope); - $hasOffsetValue = $type->hasOffsetValueType($dimType); if (!$type->isOffsetAccessible()->yes()) { return $this->checkUndefinedInner($varResult, $scope); } + $dimType = $dimResult->getTypeForScope($scope); + $hasOffsetValue = $type->hasOffsetValueType($dimType); if (!$hasOffsetValue->no()) { return $this->checkUndefinedInner($varResult, $scope); } From 8b21706252ce40eaf39d11ccea28a75182997ab0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 19:41:49 +0200 Subject: [PATCH 070/378] Re-evaluate getCurrentTypesOfSpecifiedExpr on the asking scope --- src/Analyser/ExpressionResult.php | 11 +++++++++++ src/Analyser/MutatingScope.php | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index a24a0aa2ce2..dc2a9160129 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -322,4 +322,15 @@ public function getTypeForScope(MutatingScope $scope): Type return $scope->getType($this->expr); } + /** Native counterpart of getTypeForScope(). */ + public function getNativeTypeForScope(MutatingScope $scope): Type + { + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope, $this->expr)); + } + + return $scope->getNativeType($this->expr); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 19a01057755..7e0002eeac9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1159,9 +1159,13 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array return null; } + // re-evaluate on the asking scope, not the stored beforeScope: a handler + // (e.g. isset/empty via NonNullabilityHelper) may have processed the + // inner expression on a scope that strips null, so the cached type would + // be stale for the narrowing the caller is applying return [ - $result->getType(), - $result->getNativeType(), + $result->getTypeForScope($this), + $result->getNativeTypeForScope($this), ]; } From 19fd926de8951adebf7df3ebe84ffa0f3cdca8e2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 21:36:30 +0200 Subject: [PATCH 071/378] Rules\IssetCheck folds IssetabilityDescriptor instead of re-walking the AST --- src/Analyser/ExpressionResult.php | 5 + src/Analyser/IssetabilityDescriptor.php | 56 ++++++ src/Analyser/MutatingScope.php | 29 +++ src/Rules/IssetCheck.php | 184 ++++++++++-------- .../PHPStan/Rules/Variables/EmptyRuleTest.php | 2 - .../PHPStan/Rules/Variables/IssetRuleTest.php | 2 - .../Rules/Variables/NullCoalesceRuleTest.php | 2 - 7 files changed, 191 insertions(+), 89 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index dc2a9160129..5453896d5c2 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -74,6 +74,11 @@ public function getScope(): MutatingScope return $this->scope; } + public function getExpr(): Expr + { + return $this->expr; + } + public function getBeforeScope(): MutatingScope { return $this->beforeScope; diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index a060ef1ae81..869d4654d8c 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -4,6 +4,8 @@ use Closure; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticPropertyFetch; use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; @@ -29,6 +31,7 @@ final class IssetabilityDescriptor /** * @param Closure(MutatingScope): ?FoundPropertyReflection|null $reflectionResolver + * @param PropertyFetch|StaticPropertyFetch|null $propertyFetch */ private function __construct( private string $kind, @@ -54,12 +57,65 @@ public static function offset(ExpressionResult $varResult, ExpressionResult $dim /** * @param Closure(MutatingScope): ?FoundPropertyReflection $reflectionResolver + * @param PropertyFetch|StaticPropertyFetch $propertyFetch */ public static function property(?ExpressionResult $innerResult, Closure $reflectionResolver, Expr $propertyFetch): self { return new self(self::KIND_PROPERTY, innerResult: $innerResult, reflectionResolver: $reflectionResolver, propertyFetch: $propertyFetch); } + public function isVariable(): bool + { + return $this->kind === self::KIND_VARIABLE; + } + + public function isOffset(): bool + { + return $this->kind === self::KIND_OFFSET; + } + + public function isProperty(): bool + { + return $this->kind === self::KIND_PROPERTY; + } + + public function getVariableName(): ?string + { + return $this->variableName; + } + + public function getVarResult(): ?ExpressionResult + { + return $this->varResult; + } + + public function getDimResult(): ?ExpressionResult + { + return $this->dimResult; + } + + public function getInnerResult(): ?ExpressionResult + { + return $this->innerResult; + } + + public function resolvePropertyReflection(MutatingScope $scope): ?FoundPropertyReflection + { + if ($this->reflectionResolver === null) { + throw new ShouldNotHappenException(); + } + + return ($this->reflectionResolver)($scope); + } + + /** + * @return PropertyFetch|StaticPropertyFetch|null + */ + public function getPropertyFetch(): ?Expr + { + return $this->propertyFetch; + } + /** * @param callable(Type): ?bool $typeCallback */ diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7e0002eeac9..5c8aae68df6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1217,6 +1217,35 @@ public function popExpressionResultStorage(): void $this->expressionResultStorageStack->pop(); } + /** + * The isset/empty/?? chain descriptor PHPStan\Rules\IssetCheck folds. Reads + * it from the current expression-result storage; when the rule asks before + * the engine has stored the expression's result (the rule callback fires + * before the chain-link handlers run), the expression is processed on demand + * just like resolveTypeOfNewWorldHandlerNode(). + * + * @internal + */ + public function getIssetabilityDescriptor(Expr $expr): ?IssetabilityDescriptor + { + $scope = $this->toMutatingScope(); + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $result = $storage->findExpressionResult($expr); + if ($result !== null) { + return $result->getIssetabilityDescriptor(); + } + } + + $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $expr, + $scope, + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); + + return $onDemandResult->getIssetabilityDescriptor(); + } + /** * @param callable(Type): ?bool $typeCallback */ diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index 35195e7ea52..e0a2ccf409c 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -4,16 +4,18 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\IssetabilityDescriptor; +use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; -use function is_string; use function sprintf; use function str_starts_with; @@ -26,7 +28,6 @@ final class IssetCheck public function __construct( private PropertyDescriptor $propertyDescriptor, - private PropertyReflectionFinder $propertyReflectionFinder, #[AutowiredParameter] private bool $checkAdvancedIsset, #[AutowiredParameter] @@ -41,16 +42,32 @@ public function __construct( */ public function check(Expr $expr, Scope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error = null): ?IdentifierRuleError { - // mirrored in PHPStan\Analyser\MutatingScope::issetCheck() - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $scope->hasVariableType($expr->name); + $mutatingScope = $scope->toMutatingScope(); + + return $this->doCheck($mutatingScope->getIssetabilityDescriptor($expr), $expr, $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); + } + + /** + * @param ErrorIdentifier $identifier + * @param callable(Type): ?string $typeMessageCallback + */ + private function doCheck(?IssetabilityDescriptor $descriptor, Expr $expr, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error): ?IdentifierRuleError + { + // folds PHPStan\Analyser\IssetabilityDescriptor; mirrors PHPStan\Analyser\MutatingScope::issetCheck() + if ($descriptor !== null && $descriptor->isVariable()) { + $variableName = $descriptor->getVariableName(); + if ($variableName === null) { + throw new ShouldNotHappenException(); + } + + $hasVariable = $scope->hasVariableType($variableName); if ($hasVariable->maybe()) { return null; } if ($error === null) { if ($hasVariable->yes()) { - if ($expr->name === '_SESSION') { + if ($variableName === '_SESSION') { return null; } @@ -58,7 +75,7 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str if (!$type instanceof NeverType) { return $this->generateError( $type, - sprintf('Variable $%s %s always exists and', $expr->name, $operatorDescription), + sprintf('Variable $%s %s always exists and', $variableName, $operatorDescription), $typeMessageCallback, $identifier, 'variable', @@ -66,23 +83,29 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str } } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $variableName, $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } return $error; - } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { + } elseif ($descriptor !== null && $descriptor->isOffset()) { + $varResult = $descriptor->getVarResult(); + $dimResult = $descriptor->getDimResult(); + if ($varResult === null || $dimResult === null) { + throw new ShouldNotHappenException(); + } + $type = $this->treatPhpDocTypesAsCertain - ? $scope->getScopeType($expr->var) - : $scope->getScopeNativeType($expr->var); + ? $varResult->getTypeForScope($mutatingScope) + : $varResult->getNativeTypeForScope($mutatingScope); if (!$type->isOffsetAccessible()->yes()) { - return $error ?? $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + return $error ?? $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); } $dimType = $this->treatPhpDocTypesAsCertain - ? $scope->getScopeType($expr->dim) - : $scope->getScopeNativeType($expr->dim); + ? $dimResult->getTypeForScope($mutatingScope) + : $dimResult->getNativeTypeForScope($mutatingScope); $hasOffsetValue = $type->hasOffsetValueType($dimType); if ($hasOffsetValue->no()) { if (!$this->checkAdvancedIsset) { @@ -114,54 +137,48 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str ), $typeMessageCallback, $identifier, 'offset'); if ($error !== null) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + return $this->doCheck($varResult->getIssetabilityDescriptor(), $varResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); } } // Has offset, it is nullable return null; - } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { + } elseif ($descriptor !== null && $descriptor->isProperty()) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope->toMutatingScope()); - - if ($propertyReflection === null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } + $propertyFetch = $descriptor->getPropertyFetch(); + if ($propertyFetch === null) { + throw new ShouldNotHappenException(); + } + $innerResult = $descriptor->getInnerResult(); - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } + $propertyReflection = $descriptor->resolvePropertyReflection($mutatingScope); - return null; + if ($propertyReflection === null) { + return $innerResult !== null + ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) + : null; } if (!$propertyReflection->isNative()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } - - return null; + return $innerResult !== null + ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) + : null; } if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { if ( - $expr instanceof Node\Expr\PropertyFetch - && $expr->name instanceof Node\Identifier - && $expr->var instanceof Expr\Variable - && $expr->var->name === 'this' + $propertyFetch instanceof Node\Expr\PropertyFetch + && $propertyFetch->name instanceof Node\Identifier + && $propertyFetch->var instanceof Expr\Variable + && $propertyFetch->var->name === 'this' && $scope->hasExpressionType(new PropertyInitializationExpr($propertyReflection->getName()))->yes() ) { return $this->generateError( $propertyReflection->getNativeType(), sprintf( '%s %s', - $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr), + $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $propertyFetch), $operatorDescription, ), static function (Type $type) use ($typeMessageCallback): ?string { @@ -181,7 +198,7 @@ static function (Type $type) use ($typeMessageCallback): ?string { ); } - if (!$scope->hasExpressionType($expr)->yes()) { + if (!$scope->hasExpressionType($propertyFetch)->yes()) { $nativeReflection = $propertyReflection->getNativeReflection(); if ( $nativeReflection !== null @@ -193,29 +210,17 @@ static function (Type $type) use ($typeMessageCallback): ?string { } } - $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr); + $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $propertyFetch); $propertyType = $propertyReflection->getWritableType(); if ($error !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - if ($expr->class instanceof Expr) { - return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - return $error; + return $innerResult !== null + ? $this->doCheck($innerResult->getIssetabilityDescriptor(), $innerResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error) + : $error; } if (!$this->checkAdvancedIsset) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } - - return null; + return $innerResult !== null + ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) + : null; } $error = $this->generateError( @@ -226,14 +231,8 @@ static function (Type $type) use ($typeMessageCallback): ?string { 'property', ); - if ($error !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - if ($expr->class instanceof Expr) { - return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } + if ($error !== null && $innerResult !== null) { + return $this->doCheck($innerResult->getIssetabilityDescriptor(), $innerResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); } return $error; @@ -276,29 +275,48 @@ static function (Type $type) use ($typeMessageCallback): ?string { /** * @param ErrorIdentifier $identifier */ - private function checkUndefined(Expr $expr, Scope $scope, string $operatorDescription, string $identifier): ?IdentifierRuleError + private function checkUndefinedInner(ExpressionResult $inner, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier): ?IdentifierRuleError + { + return $this->checkUndefined($inner->getIssetabilityDescriptor(), $inner->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier); + } + + /** + * @param ErrorIdentifier $identifier + */ + private function checkUndefined(?IssetabilityDescriptor $descriptor, Expr $expr, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier): ?IdentifierRuleError { - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $scope->hasVariableType($expr->name); + if ($descriptor !== null && $descriptor->isVariable()) { + $variableName = $descriptor->getVariableName(); + if ($variableName === null) { + throw new ShouldNotHappenException(); + } + + $hasVariable = $scope->hasVariableType($variableName); if (!$hasVariable->no()) { return null; } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $variableName, $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } - if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr->var) : $scope->getScopeNativeType($expr->var); - $dimType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr->dim) : $scope->getScopeNativeType($expr->dim); + if ($descriptor !== null && $descriptor->isOffset()) { + $varResult = $descriptor->getVarResult(); + $dimResult = $descriptor->getDimResult(); + if ($varResult === null || $dimResult === null) { + throw new ShouldNotHappenException(); + } + + $type = $this->treatPhpDocTypesAsCertain ? $varResult->getTypeForScope($mutatingScope) : $varResult->getNativeTypeForScope($mutatingScope); + $dimType = $this->treatPhpDocTypesAsCertain ? $dimResult->getTypeForScope($mutatingScope) : $dimResult->getNativeTypeForScope($mutatingScope); $hasOffsetValue = $type->hasOffsetValueType($dimType); if (!$type->isOffsetAccessible()->yes()) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + return $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); } if (!$hasOffsetValue->no()) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + return $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); } return RuleErrorBuilder::message( @@ -311,12 +329,12 @@ private function checkUndefined(Expr $expr, Scope $scope, string $operatorDescri )->identifier(sprintf('%s.offset', $identifier))->build(); } - if ($expr instanceof Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } + if ($descriptor !== null && $descriptor->isProperty()) { + $innerResult = $descriptor->getInnerResult(); - if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + return $innerResult !== null + ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) + : null; } return null; diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 582fdeb1076..079b032e8ee 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -22,7 +21,6 @@ protected function getRule(): Rule { return new EmptyRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->treatPhpDocTypesAsCertain, )); diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 7e83c53117b..af8f1542e04 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -21,7 +20,6 @@ protected function getRule(): Rule { return new IssetRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->treatPhpDocTypesAsCertain, )); diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 93a16694250..e001d6500ed 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -20,7 +19,6 @@ protected function getRule(): Rule { return new NullCoalesceRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->shouldTreatPhpDocTypesAsCertain(), )); From 2a62b691f41dbd39239dac4bef14eacd3872248d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 22:11:45 +0200 Subject: [PATCH 072/378] MatchHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/MatchHandler.php | 69 ++++++++++++++++------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 88e80239efa..c44ca579f91 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -18,13 +18,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -49,16 +48,17 @@ use const SORT_NUMERIC; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class MatchHandler implements TypeResolvingExprHandler +final class MatchHandler implements ExprHandler { public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -68,16 +68,6 @@ public function supports(Expr $expr): bool return $expr instanceof Match_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $types = []; - foreach ($this->getArmScopesAndTypes($scope, $expr) as [$armScope, $armType]) { - $types[] = $armType; - } - - return TypeCombinator::union(...$types); - } - /** * For each reachable match arm, returns the arm's body type together with the * scope in which the match subject is narrowed to that arm's condition. This @@ -226,6 +216,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arms = $expr->arms; $armCondsToSkip = []; $armBodyScopes = []; + // Capture, for each reachable arm, the body's already-computed + // ExpressionResult together with the scope it was processed on and the + // body node itself. The typeCallback unions these inside-out instead of + // re-walking the arms (which getArmScopesAndTypes/the old resolveType + // did). The set of contributing arms mirrors getArmScopesAndTypes + // exactly. The body node is kept so the keepVoid projection (the only + // caller is getKeepVoidType, via a synthetic clone of the match) can be + // computed for it. + /** @var list $armTypeResults */ + $armTypeResults = []; if ($condType->isEnum()->yes()) { // enum match analysis would work even without this if branch // but would be much slower @@ -367,6 +367,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + $armTypeResults[] = [$armResult, $matchArmBodyScope, $arm->body]; unset($arms[$i]); } @@ -393,6 +394,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex foreach ($arms as $i => $arm) { if ($arm->conds === null) { $hasDefaultCond = true; + $defaultArmBodyScope = $matchScope; $matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); @@ -403,6 +405,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $matchScope; } + $armTypeResults[] = [$armResult, $defaultArmBodyScope, $arm->body]; continue; } @@ -459,6 +462,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + // Mirror getArmScopesAndTypes: an arm whose filtering expression is + // always false is unreachable and does not contribute to the result + // type. + $filteringExprType = $matchScope->getType($filteringExpr); + if (!$filteringExprType->isFalse()->yes()) { + $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; + } $matchScope = $armCondScope->filterByFalseyValue($filteringExpr); } @@ -512,6 +522,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // Each arm body was already processed on the scope where the subject + // is narrowed to that arm's condition - those captured scopes are the + // evaluation points, so the result type is just the union of the arm + // body types, no re-walk of the arms needed. + typeCallback: static function (MutatingScope $s) use ($expr, $armTypeResults): Type { + $keepVoid = $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true; + $types = []; + foreach ($armTypeResults as [$armResult, $bodyScope, $armBody]) { + if ($s->nativeTypesPromoted) { + $bodyScope = $bodyScope->doNotTreatPhpDocTypesAsCertain(); + } + if ($keepVoid) { + // The only caller is getKeepVoidType (via a synthetic + // clone of the match) - it keeps void in the arm bodies + // instead of transforming it to null. + $types[] = $bodyScope->getKeepVoidType($armBody); + } else { + $types[] = $armResult->getTypeForScope($bodyScope); + } + } + + return TypeCombinator::union(...$types); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -590,9 +624,4 @@ private function scopeHasNeverVariable(MutatingScope $scope, array $varNames): b return false; } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From c8d144af9fafddb5cf08b0caadfcb792adaccb0b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 22:13:53 +0200 Subject: [PATCH 073/378] NullsafePropertyFetchHandler and NullsafeMethodCallHandler are no longer TypeResolvingExprHandler --- .../ExprHandler/NullsafeMethodCallHandler.php | 91 +++++++++---------- .../NullsafePropertyFetchHandler.php | 83 +++++++++-------- 2 files changed, 86 insertions(+), 88 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 9f8ec0fce9f..fd3d7c61c67 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -14,12 +14,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,15 +30,17 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class NullsafeMethodCallHandler implements TypeResolvingExprHandler +final class NullsafeMethodCallHandler implements ExprHandler { public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -48,42 +50,6 @@ public function supports(Expr $expr): bool return $expr instanceof NullsafeMethodCall; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - if ($varType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($varType)) { - return $scope->getType(new MethodCall($expr->var, $expr->name, $expr->args)); - } - - return TypeCombinator::union( - $scope->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new MethodCall($expr->var, $expr->name, $expr->args)), - new NullType(), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - $types = $typeSpecifier->specifyTypesInCondition( - $scope, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - new MethodCall($expr->var, $expr->name, $expr->args), - ), - $context, - )->setRootExpr($expr); - - $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -93,14 +59,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); + $methodCall = new MethodCall( + $expr->var, + $expr->name, + $expr->args, + $attributes, + ); $exprResult = $nodeScopeResolver->processExprNode( $stmt, - new MethodCall( - $expr->var, - $expr->name, - $expr->args, - $attributes, - ), + $methodCall, $nonNullabilityResult->getScope(), $storage, $nodeCallback, @@ -127,6 +94,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult): Type { + $varType = $s->getType($expr->var); + if ($varType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($varType)) { + return $exprResult->getTypeForScope($s); + } + + return TypeCombinator::union( + $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) + ->getType(new MethodCall($expr->var, $expr->name, $expr->args)), + new NullType(), + ); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $types = $this->typeSpecifier->specifyTypesInCondition( + $s, + new BooleanAnd( + new NotIdentical($expr->var, new ConstFetch(new Name('null'))), + $methodCall, + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s)->intersectWith($nullSafeTypes->normalize($s)); + }, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 26bd5d41224..d2efe1ea4f3 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -14,12 +14,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -30,15 +30,17 @@ use function array_merge; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class NullsafePropertyFetchHandler implements TypeResolvingExprHandler +final class NullsafePropertyFetchHandler implements ExprHandler { public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -48,53 +50,18 @@ public function supports(Expr $expr): bool return $expr instanceof NullsafePropertyFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - if ($varType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($varType)) { - return $scope->getType(new PropertyFetch($expr->var, $expr->name)); - } - - return TypeCombinator::union( - $scope->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new PropertyFetch($expr->var, $expr->name)), - new NullType(), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - $types = $typeSpecifier->specifyTypesInCondition( - $scope, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - new PropertyFetch($expr->var, $expr->name), - ), - $context, - )->setRootExpr($expr); - - $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode($stmt, new PropertyFetch( + $propertyFetch = new PropertyFetch( $expr->var, $expr->name, $attributes, - ), $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); + ); + $exprResult = $nodeScopeResolver->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); return $this->expressionResultFactory->create( @@ -106,6 +73,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult): Type { + $varType = $s->getType($expr->var); + if ($varType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($varType)) { + return $exprResult->getTypeForScope($s); + } + + return TypeCombinator::union( + $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) + ->getType(new PropertyFetch($expr->var, $expr->name)), + new NullType(), + ); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $types = $this->typeSpecifier->specifyTypesInCondition( + $s, + new BooleanAnd( + new NotIdentical($expr->var, new ConstFetch(new Name('null'))), + $propertyFetch, + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s)->intersectWith($nullSafeTypes->normalize($s)); + }, ); } From 3adefdad7a41efe1bf1209e784c6a062fb19dfff Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 22:34:25 +0200 Subject: [PATCH 074/378] Move first-class callable type resolution into the *CallableNode handlers --- .../FirstClassCallableFuncCallHandler.php | 81 ------------------ .../FirstClassCallableMethodCallHandler.php | 82 ------------------- .../FirstClassCallableNewHandler.php | 67 --------------- .../FirstClassCallableStaticCallHandler.php | 66 --------------- src/Analyser/ExprHandler/PipeHandler.php | 18 ++-- .../Virtual/FunctionCallableNodeHandler.php | 29 ++++++- .../InstantiationCallableNodeHandler.php | 8 +- .../Virtual/MethodCallableNodeHandler.php | 30 ++++++- .../StaticMethodCallableNodeHandler.php | 8 +- src/Analyser/MutatingScope.php | 8 ++ src/Analyser/NodeScopeResolver.php | 3 + 11 files changed, 82 insertions(+), 318 deletions(-) delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableNewHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php diff --git a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php deleted file mode 100644 index 2fafbee4dbe..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableFuncCallHandler implements TypeResolvingExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof FuncCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof Expr) { - $callableType = $scope->getType($expr->name); - if (!$callableType->isCallable()->yes()) { - return new ObjectType(Closure::class); - } - - return $this->initializerExprTypeResolver->createFirstClassCallable( - null, - $callableType->getCallableParametersAcceptors($scope), - $scope->nativeTypesPromoted, - ); - } - - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php deleted file mode 100644 index e487de41fb6..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php +++ /dev/null @@ -1,82 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableMethodCallHandler implements TypeResolvingExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof MethodCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if (!$expr->name instanceof Identifier) { - return new ObjectType(Closure::class); - } - - $varType = $scope->getType($expr->var); - $method = $scope->getMethodReflection($varType, $expr->name->toString()); - if ($method === null) { - return new ObjectType(Closure::class); - } - - return $this->initializerExprTypeResolver->createFirstClassCallable( - $method, - $method->getVariants(), - $scope->nativeTypesPromoted, - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php b/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php deleted file mode 100644 index 9f5e05c198d..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php +++ /dev/null @@ -1,67 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableNewHandler implements TypeResolvingExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof New_ && !$expr->class instanceof Class_ && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php deleted file mode 100644 index 4a5c3cd0645..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php +++ /dev/null @@ -1,66 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableStaticCallHandler implements TypeResolvingExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof StaticCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 6f27e615d52..fc55dd0b67b 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -20,7 +20,10 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\FunctionCallableNode; +use PHPStan\Node\MethodCallableNode; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Parser\ReversePipeTransformerVisitor; use PHPStan\Type\Type; use function array_merge; @@ -50,32 +53,34 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex unset($rightAttributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $argAttributes = $expr->getAttribute(ReversePipeTransformerVisitor::ARG_ATTRIBUTES_NAME, []); - $isRightFirstClassCallable = false; + $firstClassCallableNode = null; if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { $callExpr = new FuncCall($expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new FunctionCallableNode($expr->right->name, $expr->right); } elseif ($expr->right instanceof MethodCall && $expr->right->isFirstClassCallable()) { $callExpr = new MethodCall($expr->right->var, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new MethodCallableNode($expr->right->var, $expr->right->name, $expr->right); } elseif ($expr->right instanceof StaticCall && $expr->right->isFirstClassCallable()) { $callExpr = new StaticCall($expr->right->class, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new StaticMethodCallableNode($expr->right->class, $expr->right->name, $expr->right); } else { $callExpr = new FuncCall($expr->right, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); } - if ($isRightFirstClassCallable) { + if ($firstClassCallableNode !== null) { // the original first-class callable node is not processed through // processExprNode - store its result so that node callbacks asking - // about its type can be resumed + // about its type can be resumed. Its closure type lives on the + // matching *CallableNode, resolved on demand by its handler. + $callableNode = $firstClassCallableNode; $nodeScopeResolver->storeExpressionResult($storage, $expr->right, $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -84,6 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType($callableNode), )); } diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index ae01f6be2b0..59da77e1335 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -15,7 +16,9 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\FunctionCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; /** @@ -28,6 +31,7 @@ final class FunctionCallableNodeHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } @@ -61,11 +65,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - // in practice the type of the first-class callable is resolved - // by FirstClassCallableFuncCallHandler - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } + private function resolveType(MutatingScope $scope, FunctionCallableNode $expr): Type + { + $originalNode = $expr->getOriginalNode(); + if ($originalNode->name instanceof Expr) { + $callableType = $scope->getType($originalNode->name); + if (!$callableType->isCallable()->yes()) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + null, + $callableType->getCallableParametersAcceptors($scope), + $scope->nativeTypesPromoted, + ); + } + + return $this->initializerExprTypeResolver->getFirstClassCallableType($originalNode, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); + } + } diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 9fd33f592e6..c7ee4040a98 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -15,7 +15,8 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\InstantiationCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; /** @@ -28,6 +29,7 @@ final class InstantiationCallableNodeHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } @@ -61,9 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - // in practice the type of the first-class callable is resolved - // by FirstClassCallableNewHandler - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: fn (MutatingScope $scope): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 15aa475bbc9..175b0e8ebdb 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -2,7 +2,9 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use Closure; use PhpParser\Node\Expr; +use PhpParser\Node\Identifier; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -15,7 +17,8 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\MethodCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use function array_merge; @@ -29,6 +32,7 @@ final class MethodCallableNodeHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } @@ -64,11 +68,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - // in practice the type of the first-class callable is resolved - // by FirstClassCallableMethodCallHandler - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } + private function resolveType(MutatingScope $scope, MethodCallableNode $expr): Type + { + $originalNode = $expr->getOriginalNode(); + if (!$originalNode->name instanceof Identifier) { + return new ObjectType(Closure::class); + } + + $varType = $scope->getType($originalNode->var); + $method = $scope->getMethodReflection($varType, $originalNode->name->toString()); + if ($method === null) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + $method, + $method->getVariants(), + $scope->nativeTypesPromoted, + ); + } + } diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 59a5a808985..33583ff0ec5 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -15,7 +15,8 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\StaticMethodCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; use function array_merge; @@ -29,6 +30,7 @@ final class StaticMethodCallableNodeHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } @@ -70,9 +72,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - // in practice the type of the first-class callable is resolved - // by FirstClassCallableStaticCallHandler - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: fn (MutatingScope $scope): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 5c8aae68df6..4e874ca643c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1083,6 +1083,14 @@ private function resolveType(string $exprString, Expr $node): Type return $expressionType; } + // NodeScopeResolver intercepts a first-class callable CallLike before the + // ExprHandler dispatch - no handler supports the original node, its closure + // type lives on the stored result's typeCallback (see the *CallableNode + // handlers), mirroring TypeSpecifier::specifyTypesInCondition(). + if ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + return $this->resolveTypeOfNewWorldHandlerNode($node); + } + $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); if ($exprHandler !== null) { if ($exprHandler instanceof TypeResolvingExprHandler) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8e8a76c1fb2..bd4e4e3672f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2962,6 +2962,9 @@ private function processExprNodeInternal( isAlwaysTerminating: $newExprResult->isAlwaysTerminating(), throwPoints: $newExprResult->getThrowPoints(), impurePoints: $newExprResult->getImpurePoints(), + // the first-class callable closure type lives on the *CallableNode + // result; delegate so getType() of the original CallLike answers from it + typeCallback: static fn (MutatingScope $s): Type => $newExprResult->getTypeForScope($s), ); $this->storeExpressionResult($storage, $expr, $expressionResult); return $expressionResult; From 834f4732d1260d3d3ed6da9d5b86450b6658e1bf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 23:06:03 +0200 Subject: [PATCH 075/378] Price synthetic narrowing expressions on demand in getCurrentTypesOfSpecifiedExpr --- src/Analyser/MutatingScope.php | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4e874ca643c..ab72dfec1d2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1149,9 +1149,12 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type * applySpecifiedTypes() needs to intersect with or subtract from but that * is not tracked in the scope. Old-world filterBySpecifiedTypes() asked * Scope::getType() here; pricing from the stored ExpressionResult answers - * through the typeCallback for converted handlers and keeps the legacy - * resolution as a bridge for the rest. Returns null for nodes the analysis - * in progress never processed (synthetic ones). + * through the typeCallback for converted handlers. A synthetic node the + * analysis never processed - e.g. the plain-chain variant a nullsafe + * narrowing emits ($a->b() alongside $a?->b()) - is priced on demand, + * mirroring resolveTypeOfNewWorldHandlerNode(); its real subnodes answer + * from stored results so the on-demand walk terminates. Returns null only + * when there is no analysis in progress to price against. * * @return array{Type, Type}|null */ @@ -1164,7 +1167,19 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array $result = $storage->findExpressionResult($expr); if ($result === null) { - return null; + // a synthetic node - price it on demand, see + // resolveTypeOfNewWorldHandlerNode() + $scope = $this->toMutatingScope(); + $result = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $expr, + $scope, + $storage->duplicate(), + ); + + return [ + $result->getTypeForScope($scope), + $result->getNativeTypeForScope($scope), + ]; } // re-evaluate on the asking scope, not the stored beforeScope: a handler From 1d028258b6e113568dde1c32298ee38fe4ca20f9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 23:06:03 +0200 Subject: [PATCH 076/378] BinaryOpHandler and BooleanNotHandler are no longer TypeResolvingExprHandler --- phpstan-baseline.neon | 6 - src/Analyser/ExprHandler/BinaryOpHandler.php | 827 +++++++++--------- .../ExprHandler/BooleanNotHandler.php | 49 +- 3 files changed, 459 insertions(+), 423 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index dabac702b5c..3e2cb044d6e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,12 +36,6 @@ parameters: count: 2 path: src/Analyser/ExprHandler/BinaryOpHandler.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 1 - path: src/Analyser/ExprHandler/BooleanNotHandler.php - - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 516f54780a0..74046cba8ef 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -16,15 +16,15 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\RicherScopeGetTypeHelper; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -54,10 +54,10 @@ use function strtolower; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class BinaryOpHandler implements TypeResolvingExprHandler +final class BinaryOpHandler implements ExprHandler { public function __construct( @@ -68,6 +68,8 @@ public function __construct( private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -112,466 +114,503 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (MutatingScope $scope) use ($expr): Type { + $getType = static fn (Expr $expr): Type => $scope->getType($expr); - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); + if ($expr instanceof BinaryOp\Smaller) { + return $scope->getType($expr->left)->isSmallerThan($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Smaller) { - return $scope->getType($expr->left)->isSmallerThan($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $scope->getType($expr->left)->isSmallerThanOrEqual($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\SmallerOrEqual) { - return $scope->getType($expr->left)->isSmallerThanOrEqual($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\Greater) { + return $scope->getType($expr->right)->isSmallerThan($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Greater) { - return $scope->getType($expr->right)->isSmallerThan($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $scope->getType($expr->right)->isSmallerThanOrEqual($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $scope->getType($expr->right)->isSmallerThanOrEqual($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\Equal) { + return $this->resolveEqualType($scope, $expr); + } - if ($expr instanceof BinaryOp\Equal) { - if ( - $expr->left instanceof Variable - && is_string($expr->left->name) - && $expr->right instanceof Variable - && is_string($expr->right->name) - && $expr->left->name === $expr->right->name - ) { - return new ConstantBooleanType(true); - } + if ($expr instanceof BinaryOp\NotEqual) { + // negation of the Equal result - direct computation avoids + // synthesizing a BooleanNot node (which would route through + // on-demand re-processing once BooleanNot is migrated) + $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right))->toBoolean(); + if ($equalType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($equalType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + return new BooleanType(); + } - return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; - } + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr)->type; + } - if ($expr instanceof BinaryOp\NotEqual) { - return $scope->getType(new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right))); - } + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr)->type; + } - if ($expr instanceof BinaryOp\Identical) { - return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr)->type; - } + if ($expr instanceof BinaryOp\LogicalXor) { + $leftBooleanType = $scope->getType($expr->left)->toBoolean(); + $rightBooleanType = $scope->getType($expr->right)->toBoolean(); - if ($expr instanceof BinaryOp\NotIdentical) { - return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr)->type; - } + if ( + $leftBooleanType instanceof ConstantBooleanType + && $rightBooleanType instanceof ConstantBooleanType + ) { + return new ConstantBooleanType( + $leftBooleanType->getValue() xor $rightBooleanType->getValue(), + ); + } - if ($expr instanceof BinaryOp\LogicalXor) { - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - $rightBooleanType = $scope->getType($expr->right)->toBoolean(); - - if ( - $leftBooleanType instanceof ConstantBooleanType - && $rightBooleanType instanceof ConstantBooleanType - ) { - return new ConstantBooleanType( - $leftBooleanType->getValue() xor $rightBooleanType->getValue(), - ); - } + return new BooleanType(); + } - return new BooleanType(); - } + if ($expr instanceof BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Spaceship) { - return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Concat) { - return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseAnd) { - return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseOr) { - return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseXor) { - return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Div) { - return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mod) { - return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Plus) { - return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Minus) { - return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mul) { - return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Pow) { - return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftLeft) { - return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftRight) { - return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); - } + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + }, + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($expr instanceof BinaryOp\Identical) { + return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($expr, $scope, $context); + } - throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); - } + if ($expr instanceof BinaryOp\NotIdentical) { + // negating the context is exactly what a BooleanNot around the + // Identical would do - direct computation avoids synthesizing a + // BooleanNot node (on-demand re-processing once it is migrated). + // A null context never negates (BooleanNot defaults on it too). + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($expr, $scope, $context); - } + return $this->typeSpecifier->specifyTypesInCondition( + $scope, + new BinaryOp\Identical($expr->left, $expr->right), + $context->negate(), + )->setRootExpr($expr); + } - if ($expr instanceof BinaryOp\NotIdentical) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot(new BinaryOp\Identical($expr->left, $expr->right)), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof BinaryOp\Equal) { + return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($expr, $scope, $context); + } - if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($expr, $scope, $context); - } + if ($expr instanceof BinaryOp\NotEqual) { + // see NotIdentical above + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - if ($expr instanceof BinaryOp\NotEqual) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right)), - $context, - )->setRootExpr($expr); - } + return $this->typeSpecifier->specifyTypesInCondition( + $scope, + new BinaryOp\Equal($expr->left, $expr->right), + $context->negate(), + )->setRootExpr($expr); + } - if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { - if ( - $expr->left instanceof Expr\FuncCall - && $expr->left->name instanceof Name - && !$expr->left->isFirstClassCallable() - && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) - && count($expr->left->getArgs()) >= 1 - && ( - !$expr->right instanceof Expr\FuncCall - || !$expr->right->name instanceof Name - || !in_array(strtolower((string) $expr->right->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) - ) - ) { - $inverseOperator = $expr instanceof BinaryOp\Smaller - ? new BinaryOp\SmallerOrEqual($expr->right, $expr->left) - : new BinaryOp\Smaller($expr->right, $expr->left); - - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot($inverseOperator), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { + if ( + $expr->left instanceof Expr\FuncCall + && $expr->left->name instanceof Name + && !$expr->left->isFirstClassCallable() + && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + && count($expr->left->getArgs()) >= 1 + && ( + !$expr->right instanceof Expr\FuncCall + || !$expr->right->name instanceof Name + || !in_array(strtolower((string) $expr->right->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + ) + ) { + $inverseOperator = $expr instanceof BinaryOp\Smaller + ? new BinaryOp\SmallerOrEqual($expr->right, $expr->left) + : new BinaryOp\Smaller($expr->right, $expr->left); + + // negating the context is exactly what a BooleanNot around the + // inverse operator would do - direct computation avoids + // synthesizing a BooleanNot node. A null context never negates + // (BooleanNot defaults on it too). + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; - $offset = $orEqual ? 0 : 1; - $leftType = $scope->getType($expr->left); - $result = (new SpecifiedTypes([], []))->setRootExpr($expr); - - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) - && count($expr->right->getArgs()) >= 1 - && $leftType->isInteger()->yes() - ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); - - $sizeType = null; - if ($leftType instanceof ConstantIntegerType) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + return $this->typeSpecifier->specifyTypesInCondition( + $scope, + $inverseOperator, + $context->negate(), + )->setRootExpr($expr); } - } elseif ($leftType instanceof IntegerRangeType) { - if ($context->falsey() && $leftType->getMax() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + + $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; + $offset = $orEqual ? 0 : 1; + $leftType = $scope->getType($expr->left); + $result = (new SpecifiedTypes([], []))->setRootExpr($expr); + + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) + && count($expr->right->getArgs()) >= 1 + && $leftType->isInteger()->yes() + ) { + $argType = $scope->getType($expr->right->getArgs()[0]->value); + + $sizeType = null; + if ($leftType instanceof ConstantIntegerType) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + } + } elseif ($leftType instanceof IntegerRangeType) { + if ($context->falsey() && $leftType->getMax() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + } + } elseif ($context->truthy() && $leftType->getMin() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + } + } } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + $sizeType = $leftType; } - } elseif ($context->truthy() && $leftType->getMin() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + + if ($sizeType !== null) { + $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + $result = $result->unionWith($specifiedTypes); + } } - } - } else { - $sizeType = $leftType; - } - if ($sizeType !== null) { - $specifiedTypes = $typeSpecifier->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); - } - } + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + if ($context->truthy() && $argType->isArray()->maybe()) { + $countables = []; + if ($argType instanceof UnionType) { + $countableInterface = new ObjectType(Countable::class); + foreach ($argType->getTypes() as $innerType) { + if ($innerType->isArray()->yes()) { + $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); + $countables[] = $innerType; + } + + if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { + continue; + } + + $countables[] = $innerType; + } + } - if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) - ) { - if ($context->truthy() && $argType->isArray()->maybe()) { - $countables = []; - if ($argType instanceof UnionType) { - $countableInterface = new ObjectType(Countable::class); - foreach ($argType->getTypes() as $innerType) { - if ($innerType->isArray()->yes()) { - $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); - $countables[] = $innerType; + if (count($countables) > 0) { + $countableType = TypeCombinator::union(...$countables); + + return $this->typeSpecifier->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); } + } - if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { - continue; + if ($argType->isArray()->yes()) { + $newType = new NonEmptyArrayType(); + if ($context->true() && $argType->isList()->yes()) { + $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); } - $countables[] = $innerType; + $result = $result->unionWith( + $this->typeSpecifier->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), + ); } } - if (count($countables) > 0) { - $countableType = TypeCombinator::union(...$countables); - - return $typeSpecifier->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); + // infer $list[$index] after $index < count($list) + if ( + $context->true() + && !$orEqual + // constant offsets are handled via HasOffsetType/HasOffsetValueType + && !$leftType instanceof ConstantIntegerType + && $argType->isList()->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + $arrayArg = $expr->right->getArgs()[0]->value; + $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); + $result = $result->unionWith( + $this->typeSpecifier->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + ); } } - if ($argType->isArray()->yes()) { - $newType = new NonEmptyArrayType(); - if ($context->true() && $argType->isList()->yes()) { - $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); + // infer $list[$index] after $zeroOrMore < count($list) - N + // infer $list[$index] after $zeroOrMore <= count($list) - N + if ( + $context->true() + && $expr->right instanceof BinaryOp\Minus + && $expr->right->left instanceof Expr\FuncCall + && $expr->right->left->name instanceof Name + && !$expr->right->left->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) + && count($expr->right->left->getArgs()) >= 1 + // constant offsets are handled via HasOffsetType/HasOffsetValueType + && !$leftType instanceof ConstantIntegerType + && $leftType->isInteger()->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + $countArgType = $scope->getType($expr->right->left->getArgs()[0]->value); + $subtractedType = $scope->getType($expr->right->right); + if ( + $countArgType->isList()->yes() + && $this->typeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() + && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() + ) { + $arrayArg = $expr->right->left->getArgs()[0]->value; + $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); + $result = $result->unionWith( + $this->typeSpecifier->create($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + ); } + } - $result = $result->unionWith( - $typeSpecifier->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), - ); + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) + && count($expr->right->getArgs()) >= 3 + && ( + IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($leftType)->yes() + || ($expr instanceof BinaryOp\Smaller && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()) + ) + ) { + // 0 < preg_match or 1 <= preg_match becomes 1 === preg_match + $newExpr = new BinaryOp\Identical($expr->right, new Scalar\Int_(1)); + + return $this->typeSpecifier->specifyTypesInCondition($scope, $newExpr, $context)->setRootExpr($expr); } - } - // infer $list[$index] after $index < count($list) - if ( - $context->true() - && !$orEqual - // constant offsets are handled via HasOffsetType/HasOffsetValueType - && !$leftType instanceof ConstantIntegerType - && $argType->isList()->yes() - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() - ) { - $arrayArg = $expr->right->getArgs()[0]->value; - $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); - $result = $result->unionWith( - $typeSpecifier->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), - ); - } - } + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) + && count($expr->right->getArgs()) === 1 + && $leftType->isInteger()->yes() + ) { + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + $argType = $scope->getType($expr->right->getArgs()[0]->value); + if ($argType->isString()->yes()) { + $accessory = new AccessoryNonEmptyStringType(); + + if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } - // infer $list[$index] after $zeroOrMore < count($list) - N - // infer $list[$index] after $zeroOrMore <= count($list) - N - if ( - $context->true() - && $expr->right instanceof BinaryOp\Minus - && $expr->right->left instanceof Expr\FuncCall - && $expr->right->left->name instanceof Name - && !$expr->right->left->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) - && count($expr->right->left->getArgs()) >= 1 - // constant offsets are handled via HasOffsetType/HasOffsetValueType - && !$leftType instanceof ConstantIntegerType - && $leftType->isInteger()->yes() - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() - ) { - $countArgType = $scope->getType($expr->right->left->getArgs()[0]->value); - $subtractedType = $scope->getType($expr->right->right); - if ( - $countArgType->isList()->yes() - && $typeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() - && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() - ) { - $arrayArg = $expr->right->left->getArgs()[0]->value; - $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); - $result = $result->unionWith( - $typeSpecifier->create($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), - ); - } - } + $result = $result->unionWith($this->typeSpecifier->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + } + } + } - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) - && count($expr->right->getArgs()) >= 3 - && ( - IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($leftType)->yes() - || ($expr instanceof BinaryOp\Smaller && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()) - ) - ) { - // 0 < preg_match or 1 <= preg_match becomes 1 === preg_match - $newExpr = new BinaryOp\Identical($expr->right, new Scalar\Int_(1)); - - return $typeSpecifier->specifyTypesInCondition($scope, $newExpr, $context)->setRootExpr($expr); - } + if ($leftType instanceof ConstantIntegerType) { + if ($expr->right instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset + 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset - 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PreInc || $expr->right instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset), + $context, + )); + } + } - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) - && count($expr->right->getArgs()) === 1 - && $leftType->isInteger()->yes() - ) { - if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) - ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); - if ($argType->isString()->yes()) { - $accessory = new AccessoryNonEmptyStringType(); - - if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { - $accessory = new AccessoryNonFalsyStringType(); + $rightType = $scope->getType($expr->right); + if ($rightType instanceof ConstantIntegerType) { + if ($expr->left instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset + 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset - 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PreInc || $expr->left instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset), + $context, + )); } + } - $result = $result->unionWith($typeSpecifier->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + if ($context->true()) { + if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->typeSpecifier->create( + $expr->left, + $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->typeSpecifier->create( + $expr->right, + $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + } elseif ($context->false()) { + if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->typeSpecifier->create( + $expr->left, + $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->typeSpecifier->create( + $expr->right, + $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } } - } - } - if ($leftType instanceof ConstantIntegerType) { - if ($expr->right instanceof Expr\PostInc) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset + 1), - $context, - )); - } elseif ($expr->right instanceof Expr\PostDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset - 1), - $context, - )); - } elseif ($expr->right instanceof Expr\PreInc || $expr->right instanceof Expr\PreDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset), - $context, - )); + return $result; } - } - $rightType = $scope->getType($expr->right); - if ($rightType instanceof ConstantIntegerType) { - if ($expr->left instanceof Expr\PostInc) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset + 1), - $context, - )); - } elseif ($expr->left instanceof Expr\PostDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset - 1), - $context, - )); - } elseif ($expr->left instanceof Expr\PreInc || $expr->left instanceof Expr\PreDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset), - $context, - )); + if ($expr instanceof BinaryOp\Greater) { + return $this->typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); } - } - if ($context->true()) { - if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->left, - $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->right, - $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - } elseif ($context->false()) { - if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->left, - $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->right, - $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $this->typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); } - } - return $result; - } + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + }, + ); + } - if ($expr instanceof BinaryOp\Greater) { - return $typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); + /** + * The boolean result of a `==` comparison, including the same-variable + * special case. Shared by the Equal and NotEqual type callbacks. + */ + private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr): Type + { + if ( + $expr->left instanceof Variable + && is_string($expr->left->name) + && $expr->right instanceof Variable + && is_string($expr->right->name) + && $expr->left->name === $expr->right->name + ) { + return new ConstantBooleanType(true); } - if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); - } + $leftType = $scope->getType($expr->left); + $rightType = $scope->getType($expr->right); - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; } private function createRangeTypes(?Expr $rootExpr, Expr $expr, Type $type, TypeSpecifierContext $context): SpecifiedTypes diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index fc4f263faee..09521ee26c9 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -9,11 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -22,13 +22,17 @@ use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class BooleanNotHandler implements TypeResolvingExprHandler +final class BooleanNotHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,26 +55,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $exprBooleanType = $scope->getType($expr->expr)->toBoolean(); - if ($exprBooleanType instanceof ConstantBooleanType) { - return new ConstantBooleanType(!$exprBooleanType->getValue()); - } + typeCallback: static function (MutatingScope $s) use ($exprResult): Type { + $exprBooleanType = $exprResult->getTypeForScope($s)->toBoolean(); + if ($exprBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($exprBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } - return new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - return $typeSpecifier->specifyTypesInCondition($scope, $expr->expr, $context->negate())->setRootExpr($expr); + return $this->typeSpecifier->specifyTypesInCondition($s, $expr->expr, $context->negate())->setRootExpr($expr); + }, + ); } } From d0c98e13c2adac278fc2aa6df775c18dab0dba8a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 23:28:39 +0200 Subject: [PATCH 077/378] Fix getIssetabilityDescriptor shadowed by descriptor-less assignment-target placeholders --- src/Analyser/ExpressionResult.php | 13 ------------- src/Analyser/MutatingScope.php | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 5453896d5c2..9bc2dc335b5 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -130,19 +130,6 @@ public function issetCheckUndefined(MutatingScope $scope): ?bool return $this->issetabilityDescriptor?->checkUndefined($scope); } - /** Whether isset($expr) is definitely true/false (null = maybe). */ - public function isset(MutatingScope $scope): ?bool - { - return $this->issetCheck($scope, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - return !$isNull->yes(); - }); - } - /** * Whether $expr is definitely set-and-non-falsey (i.e. the negation of * empty($expr)); null = maybe. EmptyHandler negates the result. diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ab72dfec1d2..13ebc2389be 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1253,17 +1253,27 @@ public function getIssetabilityDescriptor(Expr $expr): ?IssetabilityDescriptor { $scope = $this->toMutatingScope(); $storage = $this->expressionResultStorageStack->getCurrent(); + $onDemandStorage = $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(); if ($storage !== null) { $result = $storage->findExpressionResult($expr); if ($result !== null) { - return $result->getIssetabilityDescriptor(); + $descriptor = $result->getIssetabilityDescriptor(); + if ($descriptor !== null) { + return $descriptor; + } + + // a placeholder result (e.g. the var of `$x['k'] ??= …`, stored + // as an assignment target) carries no descriptor; re-process on a + // fresh storage so the placeholder doesn't shadow the real one + // (processExprOnDemand returns stored results, incl. the placeholder) + $onDemandStorage = new ExpressionResultStorage(); } } $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $expr, $scope, - $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + $onDemandStorage, ); return $onDemandResult->getIssetabilityDescriptor(); From b885cd2e618119a6d5cc3ab6c74a7a678e3501b4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 07:27:03 +0200 Subject: [PATCH 078/378] Eliminate the OriginalPropertyTypeExpr virtual node --- src/Analyser/ExprHandler/AssignHandler.php | 24 +++++- .../OriginalPropertyTypeExprHandler.php | 74 ------------------- .../SetExistingOffsetValueTypeExprHandler.php | 11 +-- .../Virtual/SetOffsetValueTypeExprHandler.php | 11 +-- src/Node/Expr/OriginalPropertyTypeExpr.php | 37 ---------- src/Node/Printer/Printer.php | 6 -- 6 files changed, 23 insertions(+), 140 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php delete mode 100644 src/Node/Expr/OriginalPropertyTypeExpr.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1285155fd1e..68d2da85524 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -43,7 +43,6 @@ use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Node\Expr\TypeExpr; @@ -53,6 +52,7 @@ use PHPStan\Node\VariableAssignNode; use PHPStan\Node\VirtualNode; use PHPStan\Php\PhpVersion; +use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -74,6 +74,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; +use PHPStan\Type\UnionType; use TypeError; use function array_key_last; use function array_merge; @@ -99,6 +100,7 @@ public function __construct( private MatchHandler $matchHandler, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private PropertyReflectionFinder $propertyReflectionFinder, ) { } @@ -572,7 +574,7 @@ public function processAssignVar( while ($var instanceof ArrayDimFetch) { $varForSetOffsetValue = $var->var; if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); } if ( @@ -1011,7 +1013,7 @@ public function processAssignVar( while ($var instanceof ExistingArrayDimFetch) { $varForSetOffsetValue = $var->getVar(); if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); } $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( $varForSetOffsetValue, @@ -1669,4 +1671,20 @@ private function isSameVariable(Expr $a, Expr $b): bool return false; } + /** + * Returns the property's readable (declared) type, filtered down to the union + * members that are not disjoint from the currently narrowed property type. + */ + private function getOriginalPropertyType(PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type + { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); + $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); + if ($originalPropertyType instanceof UnionType) { + $currentPropertyType = $scope->getType($propertyFetch); + $originalPropertyType = $originalPropertyType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); + } + + return $originalPropertyType; + } + } diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php deleted file mode 100644 index 0e346c921c5..00000000000 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ -#[AutowiredService] -final class OriginalPropertyTypeExprHandler implements TypeResolvingExprHandler -{ - - public function __construct( - private PropertyReflectionFinder $propertyReflectionFinder, - private ExpressionResultFactory $expressionResultFactory, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof OriginalPropertyTypeExpr; - } - - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr->getPropertyFetch(), $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } - - return $propertyReflection->getReadableType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 56a154af6f8..7957ef0c797 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -16,10 +16,8 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; /** * @implements TypeResolvingExprHandler @@ -55,14 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varNode = $expr->getVar(); - $varType = $scope->getType($varNode); - if ($varNode instanceof OriginalPropertyTypeExpr) { - $currentPropertyType = $scope->getType($varNode->getPropertyFetch()); - if ($varType instanceof UnionType) { - $varType = $varType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); - } - } + $varType = $scope->getType($expr->getVar()); return $varType->setExistingOffsetValueType( $scope->getType($expr->getDim()), $scope->getType($expr->getValue()), diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 08659b35101..e8871e56a07 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -16,10 +16,8 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; /** * @implements TypeResolvingExprHandler @@ -55,14 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varNode = $expr->getVar(); - $varType = $scope->getType($varNode); - if ($varNode instanceof OriginalPropertyTypeExpr) { - $currentPropertyType = $scope->getType($varNode->getPropertyFetch()); - if ($varType instanceof UnionType) { - $varType = $varType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); - } - } + $varType = $scope->getType($expr->getVar()); return $varType->setOffsetValueType( $expr->getDim() !== null ? $scope->getType($expr->getDim()) : null, $scope->getType($expr->getValue()), diff --git a/src/Node/Expr/OriginalPropertyTypeExpr.php b/src/Node/Expr/OriginalPropertyTypeExpr.php deleted file mode 100644 index d662dbe488f..00000000000 --- a/src/Node/Expr/OriginalPropertyTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -propertyFetch; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_OriginalPropertyTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 645ffb0de43..92e06194109 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -20,7 +20,6 @@ use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; @@ -98,11 +97,6 @@ protected function pPHPStan_Node_ExistingArrayDimFetch(ExistingArrayDimFetch $ex return sprintf('__phpstanExistingArrayDimFetch(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); } - protected function pPHPStan_Node_OriginalPropertyTypeExpr(OriginalPropertyTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanOriginalPropertyType(%s)', $this->p($expr->getPropertyFetch())); - } - protected function pPHPStan_Node_SetOffsetValueTypeExpr(SetOffsetValueTypeExpr $expr): string // phpcs:ignore { return sprintf('__phpstanSetOffsetValueType(%s, %s, %s)', $this->p($expr->getVar()), $expr->getDim() !== null ? $this->p($expr->getDim()) : 'null', $this->p($expr->getValue())); From 6bfa8509812517b3e891b6d4a60430429df77425 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 08:08:29 +0200 Subject: [PATCH 079/378] Eliminate the GetOffsetValueTypeExpr virtual node --- src/Analyser/ExprHandler/AssignHandler.php | 3 +- .../Virtual/GetOffsetValueTypeExprHandler.php | 64 ------------------- src/Node/Expr/GetOffsetValueTypeExpr.php | 42 ------------ src/Node/Printer/Printer.php | 6 -- src/Rules/PhpDoc/VarTagTypeRuleHelper.php | 4 +- 5 files changed, 3 insertions(+), 116 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php delete mode 100644 src/Node/Expr/GetOffsetValueTypeExpr.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 68d2da85524..d54121786cd 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -41,7 +41,6 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; @@ -988,7 +987,7 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } - $getOffsetValueTypeExpr = new GetOffsetValueTypeExpr($assignedExpr, $dimExpr); + $getOffsetValueTypeExpr = new TypeExpr($scope->getType($assignedExpr)->getOffsetValueType($scope->getType($dimExpr))); $result = $this->processAssignVar( $nodeScopeResolver, $scope, diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php deleted file mode 100644 index ad70185384f..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -#[AutowiredService] -final class GetOffsetValueTypeExprHandler implements TypeResolvingExprHandler -{ - - public function __construct(private ExpressionResultFactory $expressionResultFactory) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof GetOffsetValueTypeExpr; - } - - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->getVar())->getOffsetValueType($scope->getType($expr->getDim())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Node/Expr/GetOffsetValueTypeExpr.php b/src/Node/Expr/GetOffsetValueTypeExpr.php deleted file mode 100644 index 38226855557..00000000000 --- a/src/Node/Expr/GetOffsetValueTypeExpr.php +++ /dev/null @@ -1,42 +0,0 @@ -var; - } - - public function getDim(): Expr - { - return $this->dim; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetOffsetValueTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 92e06194109..50f5f81eae8 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -15,7 +15,6 @@ use PHPStan\Node\Expr\ForeachValueByRefExpr; use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; @@ -72,11 +71,6 @@ protected function pPHPStan_Node_NativeTypeExpr(NativeTypeExpr $expr): string // return sprintf('__phpstanNativeType(%s, %s)', $expr->getPhpDocType()->describe(VerbosityLevel::precise()), $expr->getNativeType()->describe(VerbosityLevel::precise())); } - protected function pPHPStan_Node_GetOffsetValueTypeExpr(GetOffsetValueTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetOffsetValueType(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); - } - protected function pPHPStan_Node_UnsetOffsetExpr(UnsetOffsetExpr $expr): string // phpcs:ignore { return sprintf('__phpstanUnsetOffset(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); diff --git a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php index 4a6e8421d91..127501aa58c 100644 --- a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php +++ b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php @@ -8,7 +8,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\PhpDoc\NameScopeAlreadyBeingCreatedException; use PHPStan\PhpDoc\Tag\VarTag; use PHPStan\PhpDoc\TypeNodeResolver; @@ -78,7 +78,7 @@ public function checkVarType(Scope $scope, Node\Expr $var, Node\Expr $expr, arra $dimExpr = $arrayItem->key; } - $itemErrors = $this->checkVarType($scope, $arrayItem->value, new GetOffsetValueTypeExpr($expr, $dimExpr), $varTags, $assignedVariables); + $itemErrors = $this->checkVarType($scope, $arrayItem->value, new TypeExpr($scope->getType($expr)->getOffsetValueType($scope->getType($dimExpr))), $varTags, $assignedVariables); foreach ($itemErrors as $error) { $errors[] = $error; } From 73c945b86ad707e74bae7dfd1908abfe05c601a2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 08:38:52 +0200 Subject: [PATCH 080/378] Eliminate the GetIterableKeyTypeExpr virtual node --- .../Virtual/GetIterableKeyTypeExprHandler.php | 64 ------------------- src/Analyser/MutatingScope.php | 7 +- src/Analyser/NodeScopeResolver.php | 13 +++- src/Node/Expr/GetIterableKeyTypeExpr.php | 37 ----------- src/Node/Printer/Printer.php | 6 -- src/Rules/Arrays/ArrayUnpackingRule.php | 7 +- .../PhpDoc/WrongVariableNameInVarTagRule.php | 8 ++- 7 files changed, 26 insertions(+), 116 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php delete mode 100644 src/Node/Expr/GetIterableKeyTypeExpr.php diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php deleted file mode 100644 index 6f32dd6c6ec..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -#[AutowiredService] -final class GetIterableKeyTypeExprHandler implements TypeResolvingExprHandler -{ - - public function __construct(private ExpressionResultFactory $expressionResultFactory) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof GetIterableKeyTypeExpr; - } - - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getIterableKeyType($scope->getType($expr->getExpr())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 13ebc2389be..d32b8da774f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -27,8 +27,8 @@ use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\CloneReinitializationExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; @@ -2726,7 +2726,10 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr( $iteratee, - new GetIterableKeyTypeExpr($iteratee), + new NativeTypeExpr( + $originalScope->getIterableKeyType($iterateeType), + $originalScope->getIterableKeyType($nativeIterateeType), + ), new Variable($valueName), )), $valueType, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index bd4e4e3672f..a6463e863c6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -78,8 +78,8 @@ use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; @@ -1565,7 +1565,11 @@ public function processStmtNode( $bodyScope = $scope; if ($stmt->keyVar instanceof Variable) { - $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, new GetIterableKeyTypeExpr($stmt->expr)), $originalScope, $storage); + $keyTypeExpr = new NativeTypeExpr( + $originalScope->getIterableKeyType($originalScope->getType($stmt->expr)), + $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + ); + $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, $keyTypeExpr), $originalScope, $storage); } if ($stmt->valueVar instanceof Variable) { @@ -4712,7 +4716,10 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $storage, $stmt, $stmt->keyVar, - new GetIterableKeyTypeExpr($stmt->expr), + new NativeTypeExpr( + $originalScope->getIterableKeyType($iterateeType), + $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + ), $nodeCallback, )->getScope(); $vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar)); diff --git a/src/Node/Expr/GetIterableKeyTypeExpr.php b/src/Node/Expr/GetIterableKeyTypeExpr.php deleted file mode 100644 index 60731730539..00000000000 --- a/src/Node/Expr/GetIterableKeyTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -expr; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetIterableKeyTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 50f5f81eae8..ced16045cd9 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -13,7 +13,6 @@ use PHPStan\Node\Expr\CloneReinitializationExpr; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; @@ -81,11 +80,6 @@ protected function pPHPStan_Node_GetIterableValueTypeExpr(GetIterableValueTypeEx return sprintf('__phpstanGetIterableValueType(%s)', $this->p($expr->getExpr())); } - protected function pPHPStan_Node_GetIterableKeyTypeExpr(GetIterableKeyTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetIterableKeyType(%s)', $this->p($expr->getExpr())); - } - protected function pPHPStan_Node_ExistingArrayDimFetch(ExistingArrayDimFetch $expr): string // phpcs:ignore { return sprintf('__phpstanExistingArrayDimFetch(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); diff --git a/src/Rules/Arrays/ArrayUnpackingRule.php b/src/Rules/Arrays/ArrayUnpackingRule.php index 4dc25092a9a..08cb915cad3 100644 --- a/src/Rules/Arrays/ArrayUnpackingRule.php +++ b/src/Rules/Arrays/ArrayUnpackingRule.php @@ -6,7 +6,7 @@ use PhpParser\Node\ArrayItem; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -40,7 +40,10 @@ public function processNode(Node $node, Scope $scope): array $typeResult = $this->ruleLevelHelper->findTypeToCheck( $scope, - new GetIterableKeyTypeExpr($node->value), + new NativeTypeExpr( + $scope->getIterableKeyType($scope->getType($node->value)), + $scope->getIterableKeyType($scope->getNativeType($node->value)), + ), '', static fn (Type $type): bool => $type->isString()->no(), ); diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index 6296e12ca6c..66b7ca6ce8f 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -7,8 +7,8 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\InClassMethodNode; use PHPStan\Node\InClassNode; use PHPStan\Node\InFunctionNode; @@ -271,7 +271,11 @@ private function processForeach(Scope $scope, Node\Expr $iterateeExpr, ?Node\Exp $errors[] = $error; } if ($keyVar !== null) { - foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $keyVar, new GetIterableKeyTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $keyTypeExpr = new NativeTypeExpr( + $scope->getIterableKeyType($scope->getScopeType($iterateeExpr)), + $scope->getIterableKeyType($scope->getScopeNativeType($iterateeExpr)), + ); + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $keyVar, $keyTypeExpr, $varTags, $variableNames) as $error) { $errors[] = $error; } } From ee07d501cce50b702f4b8572270ede680c820367 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 09:10:26 +0200 Subject: [PATCH 081/378] Eliminate the GetIterableValueTypeExpr virtual node --- .../GetIterableValueTypeExprHandler.php | 64 ------------------- src/Analyser/NodeScopeResolver.php | 17 +++-- src/Node/Expr/GetIterableValueTypeExpr.php | 37 ----------- src/Node/Printer/Printer.php | 6 -- .../PhpDoc/WrongVariableNameInVarTagRule.php | 7 +- 5 files changed, 18 insertions(+), 113 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php delete mode 100644 src/Node/Expr/GetIterableValueTypeExpr.php diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php deleted file mode 100644 index 950d1025266..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ -#[AutowiredService] -final class GetIterableValueTypeExprHandler implements TypeResolvingExprHandler -{ - - public function __construct(private ExpressionResultFactory $expressionResultFactory) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof GetIterableValueTypeExpr; - } - - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getIterableValueType($scope->getType($expr->getExpr())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a6463e863c6..75be7b9f7ac 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -78,7 +78,6 @@ use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; @@ -1573,9 +1572,16 @@ public function processStmtNode( } if ($stmt->valueVar instanceof Variable) { - $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, new GetIterableValueTypeExpr($stmt->expr)), $originalScope, $storage); + $valueTypeExpr = new NativeTypeExpr( + $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), + $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + ); + $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, $valueTypeExpr), $originalScope, $storage); } elseif ($stmt->valueVar instanceof List_) { - $virtualAssign = new Assign($stmt->valueVar, new GetIterableValueTypeExpr($stmt->expr)); + $virtualAssign = new Assign($stmt->valueVar, new NativeTypeExpr( + $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), + $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + )); $virtualAssign->setAttributes($stmt->valueVar->getAttributes()); $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); } @@ -4701,7 +4707,10 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $storage, $stmt, $stmt->valueVar, - new GetIterableValueTypeExpr($stmt->expr), + new NativeTypeExpr( + $originalScope->getIterableValueType($iterateeType), + $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + ), $nodeCallback, )->getScope(); $vars = $this->getAssignedVariables($stmt->valueVar); diff --git a/src/Node/Expr/GetIterableValueTypeExpr.php b/src/Node/Expr/GetIterableValueTypeExpr.php deleted file mode 100644 index 642eb4870ea..00000000000 --- a/src/Node/Expr/GetIterableValueTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -expr; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetIterableValueTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index ced16045cd9..458041edfbc 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -13,7 +13,6 @@ use PHPStan\Node\Expr\CloneReinitializationExpr; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; @@ -75,11 +74,6 @@ protected function pPHPStan_Node_UnsetOffsetExpr(UnsetOffsetExpr $expr): string return sprintf('__phpstanUnsetOffset(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); } - protected function pPHPStan_Node_GetIterableValueTypeExpr(GetIterableValueTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetIterableValueType(%s)', $this->p($expr->getExpr())); - } - protected function pPHPStan_Node_ExistingArrayDimFetch(ExistingArrayDimFetch $expr): string // phpcs:ignore { return sprintf('__phpstanExistingArrayDimFetch(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index 66b7ca6ce8f..fbd3f1c7bb2 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -7,7 +7,6 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\InClassMethodNode; use PHPStan\Node\InClassNode; @@ -279,7 +278,11 @@ private function processForeach(Scope $scope, Node\Expr $iterateeExpr, ?Node\Exp $errors[] = $error; } } - foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $valueVar, new GetIterableValueTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $valueTypeExpr = new NativeTypeExpr( + $scope->getIterableValueType($scope->getScopeType($iterateeExpr)), + $scope->getIterableValueType($scope->getScopeNativeType($iterateeExpr)), + ); + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $valueVar, $valueTypeExpr, $varTags, $variableNames) as $error) { $errors[] = $error; } From 381cdafba0259dae9f8a08081e1072f6e5bd76be Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 11:23:52 +0200 Subject: [PATCH 082/378] ExistingArrayDimFetchHandler is no longer TypeResolvingExprHandler --- .../Virtual/ExistingArrayDimFetchHandler.php | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 61b7884270d..47e33d64957 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -8,22 +8,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ExistingArrayDimFetchHandler implements TypeResolvingExprHandler +final class ExistingArrayDimFetchHandler implements ExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) @@ -37,9 +33,9 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - + // virtual node: callers only read the type, computed lazily by the + // typeCallback. A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -48,17 +44,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType(new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim())), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType(new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From b71929c7b9cc932d556297660522d1f96bbee651 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 11:23:52 +0200 Subject: [PATCH 083/378] UnsetOffsetExprHandler is no longer TypeResolvingExprHandler --- .../Virtual/UnsetOffsetExprHandler.php | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 9f5e4368b27..ad884abca1a 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -8,22 +8,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\UnsetOffsetExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class UnsetOffsetExprHandler implements TypeResolvingExprHandler +final class UnsetOffsetExprHandler implements ExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) @@ -37,9 +33,9 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - + // virtual node: callers only read the type, computed lazily by the + // typeCallback. A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -48,17 +44,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->unsetOffset($s->getType($expr->getDim())), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->getVar())->unsetOffset($scope->getType($expr->getDim())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 251457f77b96ee1b893125203d4358cdf72798b9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 11:23:52 +0200 Subject: [PATCH 084/378] SetOffsetValueTypeExprHandler is no longer TypeResolvingExprHandler --- .../Virtual/SetOffsetValueTypeExprHandler.php | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index e8871e56a07..7034d884562 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -8,22 +8,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class SetOffsetValueTypeExprHandler implements TypeResolvingExprHandler +final class SetOffsetValueTypeExprHandler implements ExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) @@ -37,9 +33,9 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - + // virtual node: callers only read the type, computed lazily by the + // typeCallback. A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -48,21 +44,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->setOffsetValueType( + $expr->getDim() !== null ? $s->getType($expr->getDim()) : null, + $s->getType($expr->getValue()), + ), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->getVar()); - return $varType->setOffsetValueType( - $expr->getDim() !== null ? $scope->getType($expr->getDim()) : null, - $scope->getType($expr->getValue()), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 4d301ecde05795c4bd26bfff58aabc4ffaff6b1e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 11:23:52 +0200 Subject: [PATCH 085/378] SetExistingOffsetValueTypeExprHandler is no longer TypeResolvingExprHandler --- .../SetExistingOffsetValueTypeExprHandler.php | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 7957ef0c797..46372f00f86 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -8,22 +8,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class SetExistingOffsetValueTypeExprHandler implements TypeResolvingExprHandler +final class SetExistingOffsetValueTypeExprHandler implements ExprHandler { public function __construct(private ExpressionResultFactory $expressionResultFactory) @@ -37,9 +33,9 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - + // virtual node: callers only read the type, computed lazily by the + // typeCallback. A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -48,21 +44,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->setExistingOffsetValueType( + $s->getType($expr->getDim()), + $s->getType($expr->getValue()), + ), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->getVar()); - return $varType->setExistingOffsetValueType( - $scope->getType($expr->getDim()), - $scope->getType($expr->getValue()), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From 85f9c9fd170f7dc9b359beb4f5c6ee80011a6e2c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 14:57:08 +0200 Subject: [PATCH 086/378] Read operand types from ExpressionResults in Throw/BooleanAnd/Coalesce handlers --- src/Analyser/ExprHandler/BooleanAndHandler.php | 2 +- src/Analyser/ExprHandler/CoalesceHandler.php | 2 +- src/Analyser/ExprHandler/ThrowHandler.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 3e6854b175c..2c2e14dcb16 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -78,7 +78,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftTruthyScope = $leftResult->getTruthyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getFalseyScope(); } else { diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 0b496e8488a..3f600a64329 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -75,7 +75,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // mid-processing would take the on-demand path and recurse $rightScope = $scope->applySpecifiedTypes($this->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); - $rightExprType = $scope->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($scope); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); } else { diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 22bd0e1dc6e..89a3860f061 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -49,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex expr: $expr, hasYield: false, isAlwaysTerminating: true, - throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), + throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getTypeForScope($scope), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn (MutatingScope $scope): Type => new NonAcceptingNeverType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), From 6ac9520ef2b6484a2f0088107beedd9f737fda98 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 15:02:23 +0200 Subject: [PATCH 087/378] Read operand types from ExpressionResults in Ternary/ArrayDimFetch/PropertyFetch handlers --- src/Analyser/ExprHandler/ArrayDimFetchHandler.php | 2 +- src/Analyser/ExprHandler/PropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/TernaryHandler.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index efcb21cce2d..9d3461e7343 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $scope = $varResult->getScope(); - $varType = $scope->getType($expr->var); + $varType = $varResult->getTypeForScope($scope); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 911f9e7624e..c63300d3887 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -65,7 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = null; if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); - $propertyHolderType = $scopeBeforeVar->getType($expr->var); + $propertyHolderType = $varResult->getTypeForScope($scopeBeforeVar); $propertyReflection = $scopeBeforeVar->getInstancePropertyReflection($propertyHolderType, $propertyName); if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index a202f7d90c5..05ea5144e50 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -67,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $hasYield = $hasYield || $ifResult->hasYield(); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifTrueScope->getType($expr->if); + $ifTrueType = $ifResult->getTypeForScope($ifTrueScope); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } - $condType = $scope->getType($expr->cond); + $condType = $ternaryCondResult->getTypeForScope($scope); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; } elseif ($condType->isFalse()->yes()) { @@ -85,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $ifFalseScope->getType($expr->else); + $ifFalseType = $elseResult->getTypeForScope($ifFalseScope); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; From 2fbf2d340b0b7919a2b1556304cd31906e578f04 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 15:15:10 +0200 Subject: [PATCH 088/378] Read sub-expression types from ExpressionResults in offset Virtual handlers --- .../Virtual/ExistingArrayDimFetchHandler.php | 11 ++++++++--- .../SetExistingOffsetValueTypeExprHandler.php | 15 +++++++++++---- .../Virtual/SetOffsetValueTypeExprHandler.php | 18 +++++++++++++----- .../Virtual/UnsetOffsetExprHandler.php | 10 ++++++++-- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 47e33d64957..5645642ccfc 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -34,8 +34,13 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { // virtual node: callers only read the type, computed lazily by the - // typeCallback. A null specifyTypesCallback falls back to default - // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + // typeCallback. The plain array dim fetch is processed here (its real + // leaves are already stored by on-demand time) so the typeCallback reads + // its ExpressionResult instead of Scope::getType(). A null + // specifyTypesCallback falls back to default narrowing in TypeSpecifier, + // matching the old specifyDefaultTypes(). + $arrayDimFetchResult = $nodeScopeResolver->processExprNode($stmt, new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim()), $scope, $storage, $nodeCallback, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -44,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType(new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim())), + typeCallback: static fn (MutatingScope $s): Type => $arrayDimFetchResult->getTypeForScope($s), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 46372f00f86..59ed410e1c9 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -34,8 +34,15 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { // virtual node: callers only read the type, computed lazily by the - // typeCallback. A null specifyTypesCallback falls back to default + // typeCallback. The (synthetic) sub-expressions are processed here - by + // on-demand time their real leaves are already stored, so this reads them + // back; the typeCallback then reads the ExpressionResults instead of + // Scope::getType(). A null specifyTypesCallback falls back to default // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context); + $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->getValue(), $scope, $storage, $nodeCallback, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -44,9 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->setExistingOffsetValueType( - $s->getType($expr->getDim()), - $s->getType($expr->getValue()), + typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->setExistingOffsetValueType( + $dimResult->getTypeForScope($s), + $valueResult->getTypeForScope($s), ), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 7034d884562..1f1ca8e113f 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -34,8 +34,16 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { // virtual node: callers only read the type, computed lazily by the - // typeCallback. A null specifyTypesCallback falls back to default - // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + // typeCallback. The (synthetic) sub-expressions are processed here so the + // typeCallback reads their ExpressionResults instead of Scope::getType(). + // A null specifyTypesCallback falls back to default narrowing in + // TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $expr->getDim() !== null + ? $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context) + : null; + $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->getValue(), $scope, $storage, $nodeCallback, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -44,9 +52,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->setOffsetValueType( - $expr->getDim() !== null ? $s->getType($expr->getDim()) : null, - $s->getType($expr->getValue()), + typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->setOffsetValueType( + $dimResult !== null ? $dimResult->getTypeForScope($s) : null, + $valueResult->getTypeForScope($s), ), ); } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index ad884abca1a..c81be133e7b 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -34,8 +34,14 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { // virtual node: callers only read the type, computed lazily by the - // typeCallback. A null specifyTypesCallback falls back to default + // typeCallback. The (synthetic) sub-expressions are processed here - by + // on-demand time their real leaves are already stored, so this reads them + // back; the typeCallback then reads the ExpressionResults instead of + // Scope::getType(). A null specifyTypesCallback falls back to default // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -44,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType($expr->getVar())->unsetOffset($s->getType($expr->getDim())), + typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->unsetOffset($dimResult->getTypeForScope($s)), ); } From 9636450d6e2ee123bcedd5a67ea3efadb255d198 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 15:51:58 +0200 Subject: [PATCH 089/378] Read child/narrowed types from results instead of Scope::getType in leaf handlers --- src/Analyser/ExprHandler/ArrayHandler.php | 5 ++++- .../ExprHandler/ClassConstFetchHandler.php | 11 ++++++++--- .../ExprHandler/InstanceofHandler.php | 19 +++++++++++++------ src/Analyser/ExprHandler/VariableHandler.php | 10 +++++++--- .../Virtual/FunctionCallableNodeHandler.php | 13 ++++++++++--- .../Virtual/MethodCallableNodeHandler.php | 8 +++++--- 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 35bcd555286..a0550ffad21 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -113,7 +113,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); if ( $s->hasExpressionType($isCallableCall)->yes() - && $s->getType($isCallableCall)->isTrue()->yes() + // read the narrowed type from expressionTypes directly (the + // synthetic is_callable() call was never processed as a child), + // mirroring ConstFetchHandler's narrowed-constant lookup + && $s->expressionTypes[$s->getNodeKey($isCallableCall)]->getType()->isTrue()->yes() ) { $type = TypeCombinator::intersect($type, new CallableType()); } diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index b0f2a1d48fe..2ce737b9db7 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use function array_merge; @@ -91,9 +92,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope->isInClass() ? $scope->getClassReflection() : null, // getClassConstFetchTypeByReflection only invokes this for $expr->class // when it is an Expr, which is exactly when $classResult exists - static fn (Expr $e): Type => $classResult !== null && $e === $expr->class - ? $classResult->getTypeForScope($scope) - : $scope->getType($e), + static function (Expr $e) use ($classResult, $scope): Type { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + + return $classResult->getTypeForScope($scope); + }, ); }, specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index aaaa04fbed1..1ada2bd80b7 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; @@ -103,9 +104,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classType = new ObjectType($className); } } else { - $classNameType = $classResult !== null - ? $classResult->getTypeForScope($s) - : $s->getType($expr->class); + // this branch is only reached when $expr->class is an Expr, + // which is exactly when $classResult was set in processExpr + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classNameType = $classResult->getTypeForScope($s); $result = $classNameType->toObjectTypeForInstanceofCheck(); $classType = $result->type; $uncertainty = $result->uncertainty; @@ -149,9 +153,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } - $classNameType = $classResult !== null - ? $classResult->getTypeForScope($s) - : $s->getType($expr->class); + // this branch is only reached when $expr->class is an Expr, + // which is exactly when $classResult was set in processExpr + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classNameType = $classResult->getTypeForScope($s); $result = $classNameType->toObjectTypeForInstanceofCheck(); $type = $result->type; $uncertainty = $result->uncertainty; diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 07ab8493bf3..984640d5241 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -22,6 +22,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -69,9 +70,12 @@ public static function createTypeCallback(Variable $expr, ?ExpressionResult $nam return $s->getVariableType($expr->name); } - $nameType = $nameResult !== null - ? $nameResult->getTypeForScope($s) - : $s->getType($expr->name); + // this branch is only reached when $expr->name is an Expr, which is + // exactly when the caller (processExpr) set $nameResult + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $nameType = $nameResult->getTypeForScope($s); if (count($nameType->getConstantStrings()) > 0) { $types = []; foreach ($nameType->getConstantStrings() as $constantString) { diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index 59da77e1335..80970e939f9 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -18,6 +18,7 @@ use PHPStan\Node\FunctionCallableNode; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -48,6 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $hasYield = false; $isAlwaysTerminating = false; + $nameResult = null; if ($expr->getName() instanceof Expr) { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $scope = $nameResult->getScope(); @@ -65,16 +67,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr), + typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr, $nameResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - private function resolveType(MutatingScope $scope, FunctionCallableNode $expr): Type + private function resolveType(MutatingScope $scope, FunctionCallableNode $expr, ?ExpressionResult $nameResult): Type { $originalNode = $expr->getOriginalNode(); if ($originalNode->name instanceof Expr) { - $callableType = $scope->getType($originalNode->name); + // $originalNode->name is the same node as $expr->getName(), processed + // in processExpr exactly in this branch - read its ExpressionResult + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $callableType = $nameResult->getTypeForScope($scope); if (!$callableType->isCallable()->yes()) { return new ObjectType(Closure::class); } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 175b0e8ebdb..947d299200c 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -68,19 +68,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr), + typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr, $varResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - private function resolveType(MutatingScope $scope, MethodCallableNode $expr): Type + private function resolveType(MutatingScope $scope, MethodCallableNode $expr, ExpressionResult $varResult): Type { $originalNode = $expr->getOriginalNode(); if (!$originalNode->name instanceof Identifier) { return new ObjectType(Closure::class); } - $varType = $scope->getType($originalNode->var); + // $originalNode->var is the same node as $expr->getVar(), processed in + // processExpr - read its ExpressionResult instead of Scope::getType() + $varType = $varResult->getTypeForScope($scope); $method = $scope->getMethodReflection($varType, $originalNode->name->toString()); if ($method === null) { return new ObjectType(Closure::class); From 8bfe6a3e3fc10bfb67ae74b97312a43bdf600860 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 16:02:46 +0200 Subject: [PATCH 090/378] Process synthetic offsetGet/callable nodes in processExpr, read results in callbacks --- .../ExprHandler/ArrayDimFetchHandler.php | 25 +++++++++++-------- src/Analyser/ExprHandler/PipeHandler.php | 12 ++++----- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 9d3461e7343..9ee5588a8e8 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -77,6 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $varResult->getScope(); $varType = $varResult->getTypeForScope($scope); + $offsetGetResult = null; if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, @@ -86,6 +87,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new NoopNodeCallback(), $context, )->getThrowPoints()); + // process the offsetGet here (storage is available, so the result is + // captured - not the storage - avoiding a reference cycle) so the + // typeCallback reads its result instead of Scope::getType(). Gated by + // the same maybe-ArrayAccess condition, so plain arrays never reach it. + $offsetGetResult = $nodeScopeResolver->processExprOnDemand( + new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)]), + $scope, + $storage, + ); } return $this->expressionResultFactory->create( @@ -98,25 +108,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), - typeCallback: function (MutatingScope $s) use ($expr, $varResult, $dimResult): Type { + typeCallback: static function (MutatingScope $s) use ($varResult, $dimResult, $offsetGetResult): Type { $offsetAccessibleType = $varResult->getTypeForScope($s); $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) ? TypeCombinator::addNull($type) : $type; if ( - !$offsetAccessibleType->isArray()->yes() + $offsetGetResult !== null + && !$offsetAccessibleType->isArray()->yes() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() ) { - return $shortCircuit($s->getType( - new MethodCall( - $expr->var, - new Identifier('offsetGet'), - [ - new Arg($expr->dim), - ], - ), - )); + return $shortCircuit($offsetGetResult->getTypeForScope($s)); } return $shortCircuit($offsetAccessibleType->getOffsetValueType($dimResult->getTypeForScope($s))); diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index fc55dd0b67b..54073bc043b 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -76,11 +76,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($firstClassCallableNode !== null) { - // the original first-class callable node is not processed through - // processExprNode - store its result so that node callbacks asking - // about its type can be resumed. Its closure type lives on the - // matching *CallableNode, resolved on demand by its handler. - $callableNode = $firstClassCallableNode; + // store a result for $expr->right so node callbacks asking about its + // type can be resumed. Its closure type lives on the matching + // *CallableNode, processed here (storage is available, so the result - + // not the storage - is captured) and read back in the typeCallback. + $callableNodeResult = $nodeScopeResolver->processExprOnDemand($firstClassCallableNode, $scope, $storage); $nodeScopeResolver->storeExpressionResult($storage, $expr->right, $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -89,7 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType($callableNode), + typeCallback: static fn (MutatingScope $s): Type => $callableNodeResult->getTypeForScope($s), )); } From 5b3e6b40e0a82ceeeab2e73911ce4db4c1eb0280 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 16:22:37 +0200 Subject: [PATCH 091/378] Add readStoredOrPriceOnDemand/priceSyntheticOnDemand and use them instead of Scope::getType in property/nullsafe/boolean/assignop handlers --- src/Analyser/ExprHandler/AssignOpHandler.php | 18 +++++++--- src/Analyser/ExprHandler/BooleanOrHandler.php | 14 ++++---- .../ExprHandler/NullsafeMethodCallHandler.php | 16 ++++++--- .../NullsafePropertyFetchHandler.php | 12 ++++--- .../ExprHandler/PropertyFetchHandler.php | 21 ++++++++---- .../StaticPropertyFetchHandler.php | 23 +++++++++---- src/Analyser/MutatingScope.php | 16 +++++++++ src/Analyser/NodeScopeResolver.php | 34 +++++++++++++++++++ 8 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 3fc63421083..c2267c8c31b 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -58,11 +58,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; - $typeCallback = function (MutatingScope $s) use ($expr): Type { - $getType = static fn (Expr $e): Type => $s->getType($e); + $typeCallback = function (MutatingScope $s) use ($expr, $nodeScopeResolver): Type { + // $expr->var and $expr->expr were processed during this handler's + // processExpr (the var as the assignment target, the value expr by the + // inner closure below), so their ExpressionResults are stored - read + // them instead of re-walking via Scope::getType(). + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); if ($expr instanceof Expr\AssignOp\Coalesce) { - return $s->getType(new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes())); + // the coalesce is synthetic - price it on demand against the + // current storage, mirroring resolveTypeOfNewWorldHandlerNode(). + $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); + + return $nodeScopeResolver->priceSyntheticOnDemand($coalesce, $s); } if ($expr instanceof Expr\AssignOp\Concat) { @@ -158,7 +166,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { - $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); + $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $originalScope)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), $originalScope, @@ -179,7 +187,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $scope)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index ae4ff6c712b..4a683c37e05 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -66,7 +66,7 @@ public function supports(Expr $expr): bool * skipped: in the OR-truthy scope the arm that didn't narrow could still be * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes + private function augmentBooleanOrTruthyWithConditionalHolders(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes { $leftTruthyScope = $scope->filterByTruthyValue($expr->left); $rightTruthyScope = $rightScope->filterByTruthyValue($expr->right); @@ -98,9 +98,9 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco continue; } - $origType = $scope->getType($targetExpr); - $leftType = $leftTruthyScope->getType($targetExpr); - $rightType = $rightTruthyScope->getType($targetExpr); + $origType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftTruthyScope); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightTruthyScope); $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); @@ -128,7 +128,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftFalseyScope = $leftResult->getFalseyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getTruthyScope(); } else { @@ -171,7 +171,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); $rightScope = $s->filterByFalseyValue($expr->left); $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); @@ -190,7 +190,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftNormalized = $leftTypes->normalize($s); $rightNormalized = $rightTypes->normalize($rightScope); $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->augmentBooleanOrTruthyWithConditionalHolders($s, $rightScope, $expr, $types); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders($nodeScopeResolver, $s, $rightScope, $expr, $types); $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); } } else { diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index fd3d7c61c67..360200ecf02 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -54,7 +54,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $scopeBeforeNullsafe = $scope; - $varType = $scope->getType($expr->var); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); @@ -75,6 +74,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + // the var was processed above as the receiver of $methodCall; read its + // stored result on the original scope instead of re-walking via getType(). + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scopeBeforeNullsafe); + $varIsNull = $varType->isNull(); if ($varIsNull->yes()) { // Arguments are never evaluated when the var is always null. @@ -94,8 +97,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult): Type { - $varType = $s->getType($expr->var); + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver): Type { + // the var was processed above as the receiver of $methodCall. + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $s); if ($varType->isNull()->yes()) { return new NullType(); } @@ -103,9 +107,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $exprResult->getTypeForScope($s); } + // the plain method call on the null-removed scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + return TypeCombinator::union( - $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new MethodCall($expr->var, $expr->name, $expr->args)), + $nodeScopeResolver->priceSyntheticOnDemand(new MethodCall($expr->var, $expr->name, $expr->args), $truthyScope), new NullType(), ); }, diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index d2efe1ea4f3..5085c6c9637 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -73,8 +73,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult): Type { - $varType = $s->getType($expr->var); + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver): Type { + // the var was processed above as the receiver of $propertyFetch - + // read its stored result instead of re-walking via Scope::getType(). + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $s); if ($varType->isNull()->yes()) { return new NullType(); } @@ -82,9 +84,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $exprResult->getTypeForScope($s); } + // the plain property fetch on the null-removed scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + return TypeCombinator::union( - $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new PropertyFetch($expr->var, $expr->name)), + $nodeScopeResolver->priceSyntheticOnDemand(new PropertyFetch($expr->var, $expr->name), $truthyScope), new NullType(), ); }, diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index c63300d3887..c11bc3fd410 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -96,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($varResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult): Type { + typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult, $nodeScopeResolver): Type { // a fetch on a nullsafe chain whose receiver is currently nullable // short-circuits to null - the receiver result carries whether the // chain contains a ?-> (a plain nullable receiver does not propagate) @@ -131,14 +131,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $shortCircuit($returnType); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $s->getType($expr->name); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $s - ->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))) - ->getType( + ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + // a property fetch with a concrete name on the + // name-pinned scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))); + + return $nodeScopeResolver->priceSyntheticOnDemand( new PropertyFetch($expr->var, new Identifier($constantString->getValue())), - ), $nameType->getConstantStrings()), + $truthyScope, + ); + }, $nameType->getConstantStrings()), ); } diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 5170c3e258c..2b22b7d9772 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -96,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult): Type { + typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult, $nodeScopeResolver): Type { $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && TypeCombinator::containsNull($classResult->getTypeForScope($s)) ? TypeCombinator::addNull($type) : $type; @@ -117,7 +117,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Name) { $staticPropertyFetchedOnType = $s->resolveTypeByName($expr->class); } else { - $classType = $classResult !== null ? $classResult->getTypeForScope($s) : $s->getType($expr->class); + $classType = $classResult !== null ? $classResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $s); $staticPropertyFetchedOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); } @@ -134,12 +134,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $shortCircuit($fetchType); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $s->getType($expr->name); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $s - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue()))), $nameType->getConstantStrings()), + ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + // a static property fetch with a concrete name on the + // name-pinned scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); + + return $nodeScopeResolver->priceSyntheticOnDemand( + new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue())), + $truthyScope, + ); + }, $nameType->getConstantStrings()), ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d32b8da774f..82eb3e87e40 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1240,6 +1240,22 @@ public function popExpressionResultStorage(): void $this->expressionResultStorageStack->pop(); } + /** + * The ExpressionResultStorage of the analysis currently in progress, the one + * resolveTypeOfNewWorldHandlerNode() prices synthetic nodes against. A handler + * pricing a synthetic node from a lazily-invoked typeCallback must use this + * (not a storage captured at processExpr() time): a later re-evaluation + * (e.g. findEarlyTerminatingExpr()) runs under a different current storage, + * and the captured one would resolve the synthetic node's real subnodes from + * stale stored results. + * + * @internal + */ + public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage + { + return $this->expressionResultStorageStack->getCurrent(); + } + /** * The isset/empty/?? chain descriptor PHPStan\Rules\IssetCheck folds. Reads * it from the current expression-result storage; when the rule asks before diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 75be7b9f7ac..5cf981f586f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2902,6 +2902,40 @@ public function processExprOnDemand(Expr $expr, MutatingScope $scope, Expression } } + /** + * Reads the type, on the given scope, of a node an ExprHandler already + * processed (its ExpressionResult is in the storage of the analysis in + * progress). Used from lazily-invoked typeCallbacks instead of + * Scope::getType(): it reads the stored result rather than re-walking, and + * does not allocate a throwaway duplicate storage. Falls back to pricing the + * node as synthetic when it is not stored (e.g. a re-evaluation reached this + * before the original processing did). + */ + public function readStoredOrPriceOnDemand(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage(); + $result = $current?->findExpressionResult($expr); + if ($result !== null) { + return $result->getTypeForScope($scope); + } + + return $this->priceSyntheticOnDemand($expr, $scope); + } + + /** + * Prices a synthetic node (one an ExprHandler built itself) on a duplicate of + * the storage of the analysis currently in progress, mirroring + * MutatingScope::resolveTypeOfNewWorldHandlerNode(): the duplicate isolates + * the synthetic node's own stored result from the live storage while its real + * subnodes still resolve from the fallback. + */ + public function priceSyntheticOnDemand(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); + + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeForScope($scope); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ From 7c30c4a080f76fec71add4cfdcdf1f645e383522 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 16:26:01 +0200 Subject: [PATCH 092/378] Price synthetic unary-minus operand on demand instead of Scope::getType --- src/Analyser/ExprHandler/UnaryMinusHandler.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index b2826e29cb4..ff6dd498e1a 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -50,14 +50,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult, $nodeScopeResolver): Type { if ($e === $expr->expr) { return $exprResult->getTypeForScope($scope); } // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType - // operand) - not a child result, resolved on demand - return $scope->getType($e); + // operand) created inside getUnaryMinusType - priced on demand + return $nodeScopeResolver->priceSyntheticOnDemand($e, $scope); }), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); From 49bdd798162d814cf37b14b7cdf3fb53765179d6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 16:49:01 +0200 Subject: [PATCH 093/378] Read child/synthetic types via result or helpers in Match/BinaryOp/Equality instead of Scope::getType --- src/Analyser/ExprHandler/AssignHandler.php | 5 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 58 +++++++++------- .../Helper/EqualityTypeSpecifyingHelper.php | 68 +++++++++++-------- src/Analyser/ExprHandler/MatchHandler.php | 40 +++++++---- 4 files changed, 107 insertions(+), 64 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index d54121786cd..1c2b8a33c32 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -493,7 +493,7 @@ public function processAssignVar( if ($assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, - $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), + $this->processMatchForConditionalExpressionsAfterAssign($nodeScopeResolver, $scopeBeforeAssignEval, $var->name, $assignedExpr), ); } @@ -1270,12 +1270,13 @@ private function mergeConditionalExpressions(array $conditionalExpressions, arra * @return array */ private function processMatchForConditionalExpressionsAfterAssign( + NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, string $variableName, Match_ $expr, ): array { - $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($scope, $expr); + $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($nodeScopeResolver, $scope, $expr); if (count($armScopesAndTypes) < 2) { return []; } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 74046cba8ef..6130d65193f 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -94,7 +94,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && - !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + // the right operand was just processed on $leftResult's scope; read its + // result instead of re-walking via Scope::getType(). + !$rightResult->getTypeForScope($leftResult->getScope())->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } @@ -114,34 +116,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr): Type { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); + typeCallback: function (MutatingScope $scope) use ($expr, $nodeScopeResolver): Type { + // the operands were processed during processExpr; read their stored + // results instead of re-walking via Scope::getType(). Synthetic + // nodes the resolver builds (e.g. getDivType's Mod) are priced on + // demand by the same helper. + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); if ($expr instanceof BinaryOp\Smaller) { - return $scope->getType($expr->left)->isSmallerThan($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); + return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); } if ($expr instanceof BinaryOp\SmallerOrEqual) { - return $scope->getType($expr->left)->isSmallerThanOrEqual($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); + return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); } if ($expr instanceof BinaryOp\Greater) { - return $scope->getType($expr->right)->isSmallerThan($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); + return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); } if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $scope->getType($expr->right)->isSmallerThanOrEqual($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); + return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); } if ($expr instanceof BinaryOp\Equal) { - return $this->resolveEqualType($scope, $expr); + return $this->resolveEqualType($nodeScopeResolver, $scope, $expr); } if ($expr instanceof BinaryOp\NotEqual) { // negation of the Equal result - direct computation avoids // synthesizing a BooleanNot node (which would route through // on-demand re-processing once BooleanNot is migrated) - $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right))->toBoolean(); + $equalType = $this->resolveEqualType($nodeScopeResolver, $scope, new BinaryOp\Equal($expr->left, $expr->right))->toBoolean(); if ($equalType->isTrue()->yes()) { return new ConstantBooleanType(false); } @@ -161,8 +167,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\LogicalXor) { - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - $rightBooleanType = $scope->getType($expr->right)->toBoolean(); + $leftBooleanType = $getType($expr->left)->toBoolean(); + $rightBooleanType = $getType($expr->right)->toBoolean(); if ( $leftBooleanType instanceof ConstantBooleanType @@ -230,9 +236,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $nodeScopeResolver): SpecifiedTypes { if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($expr, $scope, $context); + return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context); } if ($expr instanceof BinaryOp\NotIdentical) { @@ -252,7 +258,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($expr, $scope, $context); + return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context); } if ($expr instanceof BinaryOp\NotEqual) { @@ -302,7 +308,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; $offset = $orEqual ? 0 : 1; - $leftType = $scope->getType($expr->left); + // the operands and their subexpressions were processed during + // processExpr; read their stored results instead of re-walking + // via Scope::getType(). + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + $leftType = $getType($expr->left); $result = (new SpecifiedTypes([], []))->setRootExpr($expr); if ( @@ -314,7 +324,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && count($expr->right->getArgs()) >= 1 && $leftType->isInteger()->yes() ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); + $argType = $getType($expr->right->getArgs()[0]->value); $sizeType = null; if ($leftType instanceof ConstantIntegerType) { @@ -421,8 +431,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $leftType->isInteger()->yes() && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() ) { - $countArgType = $scope->getType($expr->right->left->getArgs()[0]->value); - $subtractedType = $scope->getType($expr->right->right); + $countArgType = $getType($expr->right->left->getArgs()[0]->value); + $subtractedType = $getType($expr->right->right); if ( $countArgType->isList()->yes() && $this->typeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() @@ -467,7 +477,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); + $argType = $getType($expr->right->getArgs()[0]->value); if ($argType->isString()->yes()) { $accessory = new AccessoryNonEmptyStringType(); @@ -505,7 +515,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - $rightType = $scope->getType($expr->right); + $rightType = $getType($expr->right); if ($rightType instanceof ConstantIntegerType) { if ($expr->left instanceof Expr\PostInc) { $result = $result->unionWith($this->createRangeTypes( @@ -595,7 +605,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * The boolean result of a `==` comparison, including the same-variable * special case. Shared by the Equal and NotEqual type callbacks. */ - private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr): Type + private function resolveEqualType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, BinaryOp\Equal $expr): Type { if ( $expr->left instanceof Variable @@ -607,8 +617,10 @@ private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr): T return new ConstantBooleanType(true); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // the operands were processed during processExpr; read their stored + // results instead of re-walking via Scope::getType(). + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope); return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; } diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index c5162b0830e..cd02477548d 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -10,6 +10,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -67,9 +68,9 @@ public function __construct( { } - public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { - $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; $constantType = $expressions[1]; @@ -181,8 +182,10 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty } } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // the operands were processed during processExpr; read their stored results + // instead of re-walking via Scope::getType(). + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()); $leftBooleanType = $leftType->toBoolean(); if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { @@ -249,19 +252,19 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($scope)); } - public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; $rightExpr = $expr->right; // Normalize to: fn() === expr if ($rightExpr instanceof FuncCall && !$leftExpr instanceof FuncCall) { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( + $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, ), $scope, $context); } else { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( + $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $leftExpr, $rightExpr, ), $scope, $context); @@ -270,7 +273,7 @@ public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $s // merge result of fn1() === fn2() and fn2() === fn1() if ($rightExpr instanceof FuncCall && $leftExpr instanceof FuncCall) { return $specifiedTypes->unionWith( - $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( + $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, ), $scope, $context), @@ -280,7 +283,7 @@ public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $s return $specifiedTypes; } - private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; $rightExpr = $expr->right; @@ -294,7 +297,11 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $unwrappedRightExpr = $rightExpr->getExpr(); } - $rightType = $scope->getType($rightExpr); + // the operands and their subexpressions were processed during processExpr; + // read their stored results instead of re-walking via Scope::getType(). + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope->toMutatingScope()); + + $rightType = $getType($rightExpr); // (count($a) === $expr) if ( @@ -315,16 +322,16 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) && count($unwrappedRightExpr->getArgs()) >= 1 ) { - $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - $sizeType = $scope->getType($leftExpr); + $argType = $getType($unwrappedRightExpr->getArgs()[0]->value); + $sizeType = $getType($leftExpr); $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); if ($specifiedTypes !== null) { return $specifiedTypes; } - $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); + $leftArrayType = $getType($unwrappedLeftExpr->getArgs()[0]->value); + $rightArrayType = $getType($unwrappedRightExpr->getArgs()[0]->value); if ( $leftArrayType->isArray()->yes() && $rightArrayType->isArray()->yes() @@ -342,7 +349,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); @@ -405,7 +412,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); @@ -435,7 +442,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $notNullOnly = $funcName === 'array_find_key'; if ($bothDirections || $notNullOnly) { $args = $unwrappedLeftExpr->getArgs(); - $argType = $scope->getType($args[0]->value); + $argType = $getType($args[0]->value); if ($argType->isArray()->yes()) { if ($bothDirections) { return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); @@ -503,7 +510,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && isset($unwrappedLeftExpr->getArgs()[0]) && $rightType->isNonEmptyString()->yes() ) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { $specifiedTypes = new SpecifiedTypes(); @@ -545,7 +552,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp if ($rightType->isString()->yes()) { $types = null; foreach ($rightType->getConstantStrings() as $constantString) { - $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($unwrappedLeftExpr, $constantString, $context, $scope, $expr); + $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($nodeScopeResolver, $unwrappedLeftExpr, $constantString, $context, $scope, $expr); if ($specifiedType === null) { continue; @@ -566,7 +573,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } } - $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; $constantType = $expressions[1]; @@ -617,7 +624,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } } - $leftType = $scope->getType($leftExpr); + $leftType = $getType($leftExpr); // 'Foo' === $a::class if ( @@ -651,7 +658,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } if ($context->false()) { - $identicalType = $scope->getType($expr); + $identicalType = $getType($expr); if ($identicalType instanceof ConstantBooleanType) { $never = new NeverType(); $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; @@ -760,10 +767,12 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp /** * @return array{Expr, ConstantScalarType, Type}|null */ - private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array + private function findTypeExpressionsFromBinaryOperation(NodeScopeResolver $nodeScopeResolver, Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array { - $leftType = $scope->getType($binaryOperation->left); - $rightType = $scope->getType($binaryOperation->right); + // the operands were processed during processExpr; read their stored results + // instead of re-walking via Scope::getType(). + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->right, $scope->toMutatingScope()); $rightExpr = $binaryOperation->right; if ($rightExpr instanceof AlwaysRememberedExpr) { @@ -828,6 +837,7 @@ private function specifyTypesForConstantBinaryExpression( } private function specifyTypesForConstantStringBinaryExpression( + NodeScopeResolver $nodeScopeResolver, Expr $exprNode, Type $constantType, TypeSpecifierContext $context, @@ -889,7 +899,9 @@ private function specifyTypesForConstantStringBinaryExpression( && strtolower((string) $exprNode->name) === 'get_parent_class' && isset($exprNode->getArgs()[0]) ) { - $argType = $scope->getType($exprNode->getArgs()[0]->value); + // the argument was processed during processExpr; read its stored result + // instead of re-walking via Scope::getType(). + $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode->getArgs()[0]->value, $scope->toMutatingScope()); $objectType = new ObjectType($constantStringValue); $classStringType = new GenericClassStringType($objectType); @@ -932,7 +944,9 @@ private function specifyTypesForConstantStringBinaryExpression( && $constantStringValue === '' ) { $argValue = $exprNode->getArgs()[0]->value; - $argType = $scope->getType($argValue); + // the argument was processed during processExpr; read its stored result + // instead of re-walking via Scope::getType(). + $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($argValue, $scope->toMutatingScope()); if ($argType->isString()->yes()) { return $this->typeSpecifier->create( $argValue, diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index c44ca579f91..7f9fcf7c026 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -77,10 +77,12 @@ public function supports(Expr $expr): bool * * @return list */ - public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array + public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Match_ $expr): array { $cond = $expr->cond; - $condType = $scope->getType($cond); + // the subject was processed before this shadow walk runs; read its stored + // result on the incoming scope instead of re-walking via Scope::getType(). + $condType = $nodeScopeResolver->readStoredOrPriceOnDemand($cond, $scope); $armScopesAndTypes = []; $matchScope = $scope; @@ -142,7 +144,10 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array $cond, $conditionCaseType, ); - $armScopesAndTypes[] = [$armScope, $armScope->getType($arm->body)]; + // the arm body is read on the subject-narrowed scope this shadow + // walk built; that (body, narrowed-scope) pair is not stored, so + // price the body on demand against the current storage. + $armScopesAndTypes[] = [$armScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $armScope)]; unset($arms[$i]); } @@ -171,7 +176,7 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); } - $armScopesAndTypes[] = [$matchScope, $matchScope->getType($arm->body)]; + $armScopesAndTypes[] = [$matchScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $matchScope)]; continue; } @@ -181,14 +186,16 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array $filteringExpr = $this->getFilteringExprForMatchArm($expr, $arm->conds); - $filteringExprType = $matchScope->getType($filteringExpr); + // the filtering expression is synthetic - price it on demand against the + // current storage instead of re-walking via Scope::getType(). + $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); if (!$filteringExprType->isFalse()->yes()) { $truthyScope = $matchScope->filterByTruthyValue($filteringExpr); if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); } - $armScopesAndTypes[] = [$truthyScope, $truthyScope->getType($arm->body)]; + $armScopesAndTypes[] = [$truthyScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $truthyScope)]; } $matchScope = $matchScope->filterByFalseyValue($filteringExpr); @@ -201,9 +208,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $deepContext = $context->enterDeep(); - $condType = $scope->getType($expr->cond); - $condNativeType = $scope->getNativeType($expr->cond); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $deepContext); + // the subject was just processed on this scope; read its result instead of + // re-walking via Scope::getType(). + $condType = $condResult->getTypeForScope($scope); + $condNativeType = $condResult->getNativeTypeForScope($scope); $scope = $condResult->getScope(); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); @@ -429,7 +438,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armCondResult->getImpurePoints()); $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); $armCondResultScope = $armCondResult->getScope(); - $armCondType = $this->treatPhpDocTypesAsCertain ? $armCondResultScope->getType($armCondExpr) : $armCondResultScope->getNativeType($armCondExpr); + // the `subject === cond` comparison is synthetic - price it on demand + // against the current storage instead of re-walking via Scope::getType(). + $armCondType = $this->treatPhpDocTypesAsCertain + ? $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope) + : $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain()); if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } @@ -464,8 +477,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); // Mirror getArmScopesAndTypes: an arm whose filtering expression is // always false is unreachable and does not contribute to the result - // type. - $filteringExprType = $matchScope->getType($filteringExpr); + // type. The filtering expression is synthetic - price it on demand + // against the current storage instead of re-walking via Scope::getType(). + $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); if (!$filteringExprType->isFalse()->yes()) { $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; } @@ -483,7 +497,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond; if (!$isExhaustive) { - $remainingType = $matchScope->getType($expr->cond); + // the subject was processed above; read its stored result on the + // arm-narrowed scope instead of re-walking via Scope::getType(). + $remainingType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->cond, $matchScope); if ($remainingType instanceof NeverType) { $isExhaustive = true; } From 666feb90f34137bde4f9cbe98786b466858328b7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 17:02:22 +0200 Subject: [PATCH 094/378] Thread NodeScopeResolver into narrowing/throw-point helpers to avoid Scope::getType --- src/Analyser/ExprHandler/AssignOpHandler.php | 2 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 +-- .../ExprHandler/BooleanAndHandler.php | 12 ++++----- src/Analyser/ExprHandler/BooleanOrHandler.php | 10 ++++---- .../ExprHandler/CastStringHandler.php | 2 +- src/Analyser/ExprHandler/CoalesceHandler.php | 2 +- src/Analyser/ExprHandler/EmptyHandler.php | 2 +- .../ConditionalExpressionHolderHelper.php | 21 +++++++++------- .../Helper/ImplicitToStringCallHelper.php | 13 +++++++--- .../Helper/MethodThrowPointHelper.php | 14 ++++++++--- .../Helper/NonNullabilityHelper.php | 25 +++++++++++-------- .../ExprHandler/InterpolatedStringHandler.php | 2 +- src/Analyser/ExprHandler/IssetHandler.php | 2 +- .../ExprHandler/MethodCallHandler.php | 2 +- .../ExprHandler/NullsafeMethodCallHandler.php | 2 +- .../NullsafePropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/PrintHandler.php | 2 +- .../ExprHandler/StaticCallHandler.php | 2 +- src/Analyser/NodeScopeResolver.php | 2 +- 19 files changed, 71 insertions(+), 52 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index c2267c8c31b..ed0129aae74 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -192,7 +192,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 6130d65193f..3e740ee9de4 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -101,8 +101,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); - $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->left, $scope); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->right, $leftResult->getScope()); $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 2c2e14dcb16..863b6b5c91b 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -121,7 +121,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); $rightScope = $s->filterByTruthyValue($expr->left); $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); @@ -131,7 +131,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftNormalized = $leftTypes->normalize($s); $rightNormalized = $rightTypes->normalize($rightScope); $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); } if ($context->false()) { // Consequent (holder) narrowings projected by each holder: these must be @@ -177,10 +177,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $result = $result->setAlwaysOverwriteTypes(); } return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightCondTypes, $leftHolderTypes, false, true, $s, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightCondTypes, $leftHolderTypes, true, true, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, false, true, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, true, true, $s, $expr->left), ]))->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 4a683c37e05..36bd165ed9e 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -191,7 +191,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightNormalized = $rightTypes->normalize($rightScope); $types = $leftNormalized->intersectWith($rightNormalized); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($nodeScopeResolver, $s, $rightScope, $expr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); } } else { $types = $leftTypes->unionWith($rightTypes); @@ -206,10 +206,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $result = $result->setAlwaysOverwriteTypes(); } return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightTypes, $leftTypes, false, false, $s, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($s, $rightTypes, $leftTypes, true, false, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, false, false, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, true, false, $s, $expr->left), ]))->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 6f3d36f59c9..0b6d874814d 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 3f600a64329..8faff93160b 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -65,7 +65,7 @@ private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, Expressio public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->left); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 55f32fde61f..4f31028040f 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -45,7 +45,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->expr); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 3fb390488a5..dfb37041c6a 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -10,7 +10,7 @@ use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -37,6 +37,7 @@ public function __construct( } public function augmentDisjunctionTypes( + NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $rightScope, SpecifiedTypes $leftNormalized, @@ -88,9 +89,11 @@ public function augmentDisjunctionTypes( continue; } - $originalType = $scope->getType($targetExpr); - $leftType = $leftFilteredScope->getType($targetExpr); - $rightType = $rightFilteredScope->getType($targetExpr); + // the operands were processed during processExpr; read their stored + // results on these filtered scopes instead of re-walking via getType(). + $originalType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftFilteredScope); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightFilteredScope); if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { continue; @@ -141,7 +144,7 @@ public function mergeConditionalHolders(array $holderLists): array /** * @return array */ - public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, Scope $rightScope, ?Expr $holderSideExpr = null): array + public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, MutatingScope $rightScope, ?Expr $holderSideExpr = null): array { // The condition side asserts that its sub-expression evaluates truthy. // When that sub-expression is itself a compound boolean (e.g. `$a && $b`), @@ -157,7 +160,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con continue; } - $scopeType = $scope->getType($expr); + $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $conditionType = TypeCombinator::remove($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -174,7 +177,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con continue; } - $scopeType = $scope->getType($expr); + $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $conditionType = TypeCombinator::intersect($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -231,7 +234,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con } $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $targetScope->getType($expr); + $targetType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $targetScope); $holderType = $holdersFromSureTypes ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); @@ -240,7 +243,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con // holder must allow the values it excluded, or it over-narrows when // only the remaining conditions hold. So union back the complement. if ($droppedSelfCondition !== null) { - $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); + $complement = TypeCombinator::remove($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $droppedSelfCondition->getType()); if (!$complement instanceof NeverType) { $holderType = TypeCombinator::union($holderType, $complement); } diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 58ed6c39ac6..d98ff8b4c49 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use function sprintf; @@ -25,12 +26,14 @@ public function __construct( { } - public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult + public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver, Expr $expr, MutatingScope $scope): ExpressionResult { $throwPoints = []; $impurePoints = []; - $exprType = $scope->getType($expr); + // the expression was processed before this call; read its stored result + // or price it on demand instead of re-walking via Scope::getType(). + $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $toStringMethod = null; if (!$exprType->isObject()->no()) { @@ -59,12 +62,16 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E } if ($this->phpVersion->throwsOnStringCast()) { + // the __toString() call is a synthetic node - price it on demand to + // resolve its return type instead of re-walking via Scope::getType(). + $toStringCall = new Expr\MethodCall($expr, new Identifier('__toString')); $throwPoint = $this->methodThrowPointHelper->getThrowPoint( $toStringMethod, $toStringMethod->getOnlyVariant(), - new Expr\MethodCall($expr, new Identifier('__toString')), + $toStringCall, $scope, ExpressionContext::createDeep(), + $nodeScopeResolver->priceSyntheticOnDemand($toStringCall, $scope), ); if ($throwPoint !== null) { $throwPoints[] = $throwPoint; diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 08ff8735587..a786153f2d0 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -14,6 +14,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -31,12 +32,19 @@ public function __construct( { } + /** + * @param Type $methodCallReturnType the resolved return type of $normalizedMethodCall; + * passed in by the caller so this helper never asks Scope::getType() itself + * (the old-world call handlers resolve it directly, the new-world toString + * path prices the synthetic call on demand) + */ public function getThrowPoint( MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, MethodCall|StaticCall $normalizedMethodCall, MutatingScope $scope, ExpressionContext $context, + Type $methodCallReturnType, ): ?InternalThrowPoint { if ($normalizedMethodCall instanceof MethodCall) { @@ -77,8 +85,7 @@ public function getThrowPoint( $throwType = $methodReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedMethodCall); - if ($returnType instanceof NeverType && $returnType->isExplicit()) { + if ($methodCallReturnType instanceof NeverType && $methodCallReturnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } } @@ -88,8 +95,7 @@ public function getThrowPoint( return InternalThrowPoint::createExplicit($scope, $throwType, $normalizedMethodCall, true); } } elseif ($this->implicitThrows) { - $methodReturnedType = $scope->getType($normalizedMethodCall); - if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { + if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodCallReturnType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedMethodCall); } } diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 79f5fe0b67c..c3eb36a1778 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,7 +11,7 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; use PHPStan\Type\TypeCombinator; @@ -20,9 +20,12 @@ final class NonNullabilityHelper { - public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult { - $exprType = $scope->getType($exprToSpecify); + // the expression has not been processed into the storage yet (this runs + // before processExprNode), so read its type from the stored result or + // price it on demand instead of re-walking via Scope::getType(). + $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -32,9 +35,9 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getType($exprToSpecify); + $originalExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->getNativeType($exprToSpecify); + $originalNativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope->doNotTreatPhpDocTypesAsCertain()); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -52,8 +55,8 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getType($parentExpr), - $scope->getNativeType($parentExpr), + $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope), + $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope->doNotTreatPhpDocTypesAsCertain()), $originalScope->hasExpressionType($parentExpr), ); } @@ -64,7 +67,7 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $certainty = $hasExpressionType; } - $nativeType = $scope->getNativeType($exprToSpecify); + $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope->doNotTreatPhpDocTypesAsCertain()); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -79,12 +82,12 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina ); } - public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + public function ensureNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult { $specifiedExpressions = []; $originalScope = $scope; - $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope, $nodeScopeResolver) { + $result = $this->ensureShallowNonNullability($nodeScopeResolver, $scope, $originalScope, $expr); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index cf7975d713f..98d5d7868cb 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -63,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $part, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 7d379b16556..89589a74bfb 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -353,7 +353,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nonNullabilityResults = []; $isAlwaysTerminating = false; foreach ($expr->vars as $var) { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $var); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 7656e309bfa..04742be2801 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -154,7 +154,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $scope->getType($normalizedExpr)); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 360200ecf02..5f7f089a314 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -55,7 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $beforeScope = $scope; $scopeBeforeNullsafe = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $methodCall = new MethodCall( diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 5085c6c9637..1a3dd160d41 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -53,7 +53,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $propertyFetch = new PropertyFetch( diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 37172802e83..95b8b2dc76d 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -48,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 3c06b141211..cedeed87591 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -223,7 +223,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeFunction = $scope->getFunction(); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $scope->getType($normalizedExpr)); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 5cf981f586f..2b1e7bea9f5 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1160,7 +1160,7 @@ public function processStmtNode( $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($this, $echoExpr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); From a4a70dec22b552529a31b3af56e230dc8d8d520b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 18:41:23 +0200 Subject: [PATCH 095/378] Read child/synthetic types via results or helpers in AssignHandler's assign machinery --- src/Analyser/ExprHandler/AssignHandler.php | 179 +++++++++++---------- src/Analyser/NodeScopeResolver.php | 20 +++ 2 files changed, 113 insertions(+), 86 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1c2b8a33c32..d0f95ca0285 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -187,8 +187,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + $type = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -222,8 +222,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - typeCallback: static fn (MutatingScope $s): Type => $assignedExprResult !== null ? $assignedExprResult->getTypeForScope($s) : $s->getType($expr->expr), - specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($expr, $assignedExprResult) : null, + typeCallback: static fn (MutatingScope $s): Type => $assignedExprResult !== null ? $assignedExprResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s), + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : null, createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); } @@ -256,9 +256,9 @@ private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assi * * @return Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ - private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult): Closure + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Assign $expr, ?ExpressionResult $assignedExprResult): Closure { - return function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $assignedExprResult): SpecifiedTypes { + return function (MutatingScope $s, TypeSpecifierContext $context) use ($nodeScopeResolver, $expr, $assignedExprResult): SpecifiedTypes { if ($context->null()) { $specifiedTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s->exitFirstLevelStatements(), $expr->expr, $assignedExprResult, $context)->setRootExpr($expr); $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); @@ -275,7 +275,7 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass && count($expr->expr->getArgs()) >= 1 ) { $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $s->getType($arrayArg); + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); if ($arrayType->isArray()->yes()) { if ($context->true()) { @@ -293,7 +293,7 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), ); } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { - $keyType = $s->getType($expr->expr); + $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s); $nonNullKeyType = TypeCombinator::removeNull($keyType); if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( @@ -319,14 +319,14 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass if ($funcName === 'array_search') { $arrayArg = $expr->expr->getArgs()[1]->value; $sentinelType = new ConstantBooleanType(false); - $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $s->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[2]->value, $s)->isTrue()->yes(); } elseif ($funcName === 'array_find_key') { $arrayArg = $expr->expr->getArgs()[0]->value; $sentinelType = new NullType(); } if ($arrayArg !== null) { - $arrayType = $s->getType($arrayArg); + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); if ($arrayType->isArray()->yes()) { if ($context->true()) { @@ -337,7 +337,7 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); if ($isStrictArraySearch) { - $needleType = $s->getType($expr->expr->getArgs()[0]->value); + $needleType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[0]->value, $s); $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); } else { $dimFetchType = $arrayType->getIterableValueType(); @@ -347,11 +347,11 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $dimFetchType, TypeSpecifierContext::createTrue()), ); } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { - $keyType = $s->getType($expr->expr); + $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s); $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); if (!$narrowedKeyType instanceof NeverType) { if ($isStrictArraySearch) { - $needleType = $s->getType($expr->expr->getArgs()[0]->value); + $needleType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[0]->value, $s); $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); } else { $dimFetchType = $arrayType->getIterableValueType(); @@ -381,12 +381,12 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass $numArg = $args[1]->value; } $one = new ConstantIntegerType(1); - $arrayType = $s->getType($arrayArg); + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); if ( $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($s->getType($numArg))->yes()) + && ($numArg === null || $one->isSuperTypeOf($nodeScopeResolver->readStoredOrPriceOnDemand($numArg, $s))->yes()) ) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); @@ -408,7 +408,7 @@ private function createSpecifyTypesCallback(Assign $expr, ?ExpressionResult $ass && count($expr->expr->left->getArgs()) >= 1 ) { $arrayArg = $expr->expr->left->getArgs()[0]->value; - $arrayType = $s->getType($arrayArg); + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); if ( $arrayType->isList()->yes() && $arrayType->isIterableAtLeastOnce()->yes() @@ -463,7 +463,7 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $scopeBeforeAssignEval->getType($assignedExpr); + $type = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { @@ -476,17 +476,17 @@ public function processAssignVar( $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $truthyScope->getType($if); - $falseyType = $falsyScope->getType($assignedExpr->else); + $truthyType = $nodeScopeResolver->readStoredOrPriceOnDemand($if, $truthyScope); + $falseyType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr->else, $falsyScope); if ( $truthyType->isSuperTypeOf($falseyType)->no() && $falseyType->isSuperTypeOf($truthyType)->no() ) { - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } } @@ -505,13 +505,13 @@ public function processAssignVar( // case here). if ($truthyType !== $type && !$truthyType->equals($type)) { $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { @@ -540,23 +540,23 @@ public function processAssignVar( $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); + $scope = $scope->assignVariable($var->name, $type, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } if ($assignedExpr instanceof Expr\Array_) { - $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $var->name, $assignedExpr, new Variable($var->name)); } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); @@ -573,7 +573,7 @@ public function processAssignVar( while ($var instanceof ArrayDimFetch) { $varForSetOffsetValue = $var->var; if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } if ( @@ -642,8 +642,8 @@ public function processAssignVar( )); } else { - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; + $offsetTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope), $dimFetch]; + $offsetNativeTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope), $dimFetch]; if ($enterExpressionAssign) { $scope->enterExpressionAssign($dimExpr); @@ -656,7 +656,7 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $s->getType($dimFetch->var)->getOffsetValueType($s->getType($dimExpr)), + typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $s)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $s)), )); $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $result->hasYield(); @@ -669,6 +669,13 @@ public function processAssignVar( } } + // SKIPPED (single-pass inside-out invariant): these two reads must stay as + // Scope::getType()/getNativeType(). Unlike the non-caching helpers, + // Scope::getType() memoises the assigned expression's sub-expression types + // onto $scope (e.g. hasExpressionType() for the array-dim-fetch being + // written). produceArrayDimFetchAssignValueToWrite() below relies on that + // memoised state to keep a freshly-coalesced offset optional - replacing + // these with the helpers regresses bug-13623 ($x[...] ??= [] chains). $valueToWrite = $scope->getType($assignedExpr); $nativeValueToWrite = $scope->getNativeType($assignedExpr); $scopeBeforeAssignEval = $scope; @@ -681,8 +688,8 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var, $scope); + $varNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($var, $scope); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -693,10 +700,10 @@ public function processAssignVar( $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; - [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) { - [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); } else { $rewritten = false; foreach ($offsetTypes as $i => [$offsetType]) { @@ -715,7 +722,7 @@ public function processAssignVar( continue; } - [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); $rewritten = true; break; } @@ -733,7 +740,7 @@ public function processAssignVar( if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); } } $scope = $scope->assignExpression( @@ -748,7 +755,7 @@ public function processAssignVar( } elseif ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); } } } @@ -763,7 +770,7 @@ public function processAssignVar( $scope = $scope->assignExpression($expr, $type, $nativeType); } - $setVarType = $scope->getType($originalVar->var); + $setVarType = $nodeScopeResolver->readStoredOrPriceOnDemand($originalVar->var, $scope); if ( !$setVarType instanceof ErrorType && !$setVarType->isArray()->yes() @@ -810,10 +817,10 @@ public function processAssignVar( $throwPoints[] = InternalThrowPoint::createImplicit($scope, $var); } - $propertyHolderType = $scope->getType($var->var); + $propertyHolderType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -830,16 +837,16 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } $declaringClass = $propertyReflection->getDeclaringClass(); @@ -874,9 +881,9 @@ public function processAssignVar( } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); // simulate dynamic property assign by __set to get throw points if (!$propertyHolderType->hasMethod('__set')->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( @@ -895,7 +902,7 @@ public function processAssignVar( $propertyHolderType = $scope->resolveTypeByName($var->class); } else { $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); - $propertyHolderType = $scope->getType($var->class); + $propertyHolderType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->class, $scope); } $propertyName = null; @@ -920,7 +927,7 @@ public function processAssignVar( if ($propertyName !== null) { $propertyReflection = $scope->getStaticPropertyReflection($propertyHolderType, $propertyName); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -937,23 +944,23 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } elseif ($var instanceof List_) { $result = $processExprCallback($scope); @@ -987,7 +994,7 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } - $getOffsetValueTypeExpr = new TypeExpr($scope->getType($assignedExpr)->getOffsetValueType($scope->getType($dimExpr))); + $getOffsetValueTypeExpr = new TypeExpr($nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope))); $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -1012,7 +1019,7 @@ public function processAssignVar( while ($var instanceof ExistingArrayDimFetch) { $varForSetOffsetValue = $var->getVar(); if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( $varForSetOffsetValue, @@ -1033,14 +1040,14 @@ public function processAssignVar( foreach (array_reverse($dimFetchStack) as $dimFetch) { $dimExpr = $dimFetch->getDim(); $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; + $offsetTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope), $dimFetch]; + $offsetNativeTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope), $dimFetch]; } - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope); + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var, $scope); + $varNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($var, $scope); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; @@ -1133,7 +1140,7 @@ private function unwrapAssign(Expr $expr): Expr * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + private function processSureTypesForConditionalExpressionsAfterAssign(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1148,7 +1155,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $nodeScopeResolver->readStoredOrPriceOnDemand($innerExpr, $scope), TrinaryLogic::createMaybe(), ); continue; @@ -1162,7 +1169,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $exprType), TrinaryLogic::createYes(), ); } @@ -1175,7 +1182,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1204,7 +1211,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $exprType), TrinaryLogic::createYes(), ); } @@ -1405,12 +1412,12 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr return $scope->hasVariableType($varNode->name)->negate(); } - private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope + private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope { $implicitIndex = 0; foreach ($arrayExpr->items as $arrayItem) { if ($arrayItem->key !== null) { - $keyType = $scope->getType($arrayItem->key)->toArrayKey(); + $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayItem->key, $scope)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1436,7 +1443,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam if ($arrayItem->value instanceof Expr\Array_) { $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { @@ -1445,8 +1452,8 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam $refVarName = $arrayItem->value->name; $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $refType = $scope->getType(new Variable($refVarName)); - $refNativeType = $scope->getNativeType(new Variable($refVarName)); + $refType = $nodeScopeResolver->readStoredOrPriceOnDemand(new Variable($refVarName), $scope); + $refNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative(new Variable($refVarName), $scope); // When $rootVarName's array key changes, update $refVarName $scope = $scope->assignExpression( @@ -1474,7 +1481,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam * * @return array{Type, list} */ - private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope): array + private function produceArrayDimFetchAssignValueToWrite(NodeScopeResolver $nodeScopeResolver, array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, MutatingScope $scope): array { $originalValueToWrite = $valueToWrite; @@ -1493,14 +1500,14 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $has = $offsetValueType->hasOffsetValueType($offsetType); if ($has->yes()) { if ($scope->hasExpressionType($dimFetch)->yes()) { - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch, $scope); } else { $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); } } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch, $scope); } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } @@ -1578,7 +1585,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $unionValues); } - if ($arrayDimFetch !== null && $offsetValueType->isList()->yes() && $this->shouldKeepList($arrayDimFetch, $scope, $offsetValueType)) { + if ($arrayDimFetch !== null && $offsetValueType->isList()->yes() && $this->shouldKeepList($nodeScopeResolver, $arrayDimFetch, $scope, $offsetValueType)) { $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); } @@ -1601,7 +1608,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } elseif (isset($computedContainerValues[$key])) { $additionalValueType = $computedContainerValues[$key]; } else { - $offsetType = $scope->getType($dimFetch->dim); + $offsetType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->dim, $scope); $additionalValueType = $valueToWrite->getOffsetValueType($offsetType); } @@ -1611,7 +1618,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar return [$valueToWrite, $additionalExpressions]; } - private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type $offsetValueType): bool + private function shouldKeepList(NodeScopeResolver $nodeScopeResolver, ArrayDimFetch $arrayDimFetch, MutatingScope $scope, Type $offsetValueType): bool { if ($arrayDimFetch->dim instanceof Expr\BinaryOp\Plus) { if ( // keep list for $list[$index + 1] assignments @@ -1637,7 +1644,7 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type && in_array($arrayDimFetch->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($arrayDimFetch->dim->left->getArgs()) === 1 // could support COUNT_RECURSIVE, COUNT_NORMAL && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->left->getArgs()[0]->value) - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($arrayDimFetch->dim))->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($nodeScopeResolver->readStoredOrPriceOnDemand($arrayDimFetch->dim, $scope))->yes() && $offsetValueType->isIterableAtLeastOnce()->yes() ) { return true; @@ -1675,12 +1682,12 @@ private function isSameVariable(Expr $a, Expr $b): bool * Returns the property's readable (declared) type, filtered down to the union * members that are not disjoint from the currently narrowed property type. */ - private function getOriginalPropertyType(PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type + private function getOriginalPropertyType(NodeScopeResolver $nodeScopeResolver, PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type { $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); if ($originalPropertyType instanceof UnionType) { - $currentPropertyType = $scope->getType($propertyFetch); + $currentPropertyType = $nodeScopeResolver->readStoredOrPriceOnDemand($propertyFetch, $scope); $originalPropertyType = $originalPropertyType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 2b1e7bea9f5..f2470fd270d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2936,6 +2936,26 @@ public function priceSyntheticOnDemand(Expr $expr, MutatingScope $scope): Type return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeForScope($scope); } + /** Native counterpart of readStoredOrPriceOnDemand(). */ + public function readStoredOrPriceOnDemandNative(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage(); + $result = $current?->findExpressionResult($expr); + if ($result !== null) { + return $result->getNativeTypeForScope($scope); + } + + return $this->priceSyntheticOnDemandNative($expr, $scope); + } + + /** Native counterpart of priceSyntheticOnDemand(). */ + public function priceSyntheticOnDemandNative(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); + + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getNativeTypeForScope($scope); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ From f00bff067d4d8e7af0b01ef4ad2f07130e1c144a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 18:51:50 +0200 Subject: [PATCH 096/378] Read expr types via results or helpers in NodeScopeResolver instead of Scope::getType --- src/Analyser/NodeScopeResolver.php | 156 +++++++++++++++++------------ 1 file changed, 91 insertions(+), 65 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f2470fd270d..21eef2223ae 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -51,6 +51,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; @@ -1059,7 +1060,7 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { $nodeCallback($node, $scope); if ($scope->getFunction() !== $methodScope->getFunction()) { return; @@ -1073,7 +1074,7 @@ public function processStmtNode( && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection && $scope->getFunction()->getDeclaringClass()->hasConstructor() && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null + && TypeUtils::findThisType($this->readStoredOrPriceOnDemand($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null ) { return; } @@ -1455,8 +1456,8 @@ public function processStmtNode( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); - $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condScope->getType($elseif->cond) : $scope->getNativeType($elseif->cond))->toBoolean(); $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condScope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); $branchScopeStatementResult = $this->processStmtNodesInternal($elseif, $elseif->stmts, $condResult->getTruthyScope(), $storage, $nodeCallback, $context); @@ -1563,24 +1564,27 @@ public function processStmtNode( $originalScope = $scope; $bodyScope = $scope; + $foreachIterateeType = $condResult->getTypeForScope($originalScope); + $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); + if ($stmt->keyVar instanceof Variable) { $keyTypeExpr = new NativeTypeExpr( - $originalScope->getIterableKeyType($originalScope->getType($stmt->expr)), - $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableKeyType($foreachIterateeType), + $originalScope->getIterableKeyType($foreachNativeIterateeType), ); $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, $keyTypeExpr), $originalScope, $storage); } if ($stmt->valueVar instanceof Variable) { $valueTypeExpr = new NativeTypeExpr( - $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($foreachIterateeType), + $originalScope->getIterableValueType($foreachNativeIterateeType), ); $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, $valueTypeExpr), $originalScope, $storage); } elseif ($stmt->valueVar instanceof List_) { $virtualAssign = new Assign($stmt->valueVar, new NativeTypeExpr( - $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($foreachIterateeType), + $originalScope->getIterableValueType($foreachNativeIterateeType), )); $virtualAssign->setAttributes($stmt->valueVar->getAttributes()); $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); @@ -1593,19 +1597,21 @@ public function processStmtNode( $storage = $originalStorage->duplicate(); $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + $foreachIterateeType = $condResult->getTypeForScope($originalScope); + $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); + $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context, $foreachIterateeType, $foreachNativeIterateeType); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; $unrolledEndScope = $unrolledResult['endScope']; $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $count = 0; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -1625,7 +1631,7 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage; - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); @@ -1675,7 +1681,7 @@ public function processStmtNode( $finalScope = $unrolledEndScope; } - $exprType = $scope->getType($stmt->expr); + $exprType = $condResult->getTypeForScope($scope); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1693,8 +1699,8 @@ public function processStmtNode( foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { if ($keyVarExpr !== null) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $keyVarExpr); - $dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); + $dimFetchType = $this->priceSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType); + $dimFetchNativeType = $this->priceSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the // two describe the same element for a given iteration. If the value var @@ -1702,21 +1708,21 @@ public function processStmtNode( // the narrowed value-var type in place of the broader dim fetch type so // the loop's final array rewrite below picks up the sharper element type. if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { - $valueVarType = $scopeWithIterableValueType->getType($stmt->valueVar); + $valueVarType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType); if ($dimFetchType->isSuperTypeOf($valueVarType)->yes()) { $dimFetchType = $valueVarType; } - $valueVarNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); + $valueVarNativeType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); if ($dimFetchNativeType->isSuperTypeOf($valueVarNativeType)->yes()) { $dimFetchNativeType = $valueVarNativeType; } } - $keyLoopTypes[] = $scopeWithIterableValueType->getType($keyVarExpr); - $keyLoopNativeTypes[] = $scopeWithIterableValueType->getType($keyVarExpr); + $keyLoopTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); + $keyLoopNativeTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); } else { // No key variable: the narrowed value var is the array element type directly. - $dimFetchType = $scopeWithIterableValueType->getType($stmt->valueVar); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); + $dimFetchType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType); + $dimFetchNativeType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); } $arrayDimFetchLoopTypes[] = $dimFetchType; $arrayDimFetchLoopNativeTypes[] = $dimFetchNativeType; @@ -1728,7 +1734,7 @@ public function processStmtNode( $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); $keyTypeChanged = false; $keyLoopType = $exprType->getIterableKeyType(); - $keyLoopNativeType = $scope->getNativeType($stmt->expr)->getIterableKeyType(); + $keyLoopNativeType = $condResult->getNativeTypeForScope($scope)->getIterableKeyType(); if ($keyVarExpr !== null) { $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); @@ -1744,7 +1750,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $scope->getNativeType($stmt->expr); + $nativeExprType = $condResult->getNativeTypeForScope($scope); $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -1792,7 +1798,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); } - $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr); + $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr, $exprType); if ($traversableThrowPoint !== null) { $throwPoints[] = $traversableThrowPoint; } @@ -1812,7 +1818,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($scope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1863,7 +1869,7 @@ public function processStmtNode( $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScopeMaybeRan) : $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScopeMaybeRan->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1961,7 +1967,7 @@ public function processStmtNode( $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope) : $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -2032,7 +2038,7 @@ public function processStmtNode( // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condResultScope) : $condResult->getNativeTypeForScope($condResultScope))->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } @@ -2082,7 +2088,7 @@ public function processStmtNode( $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); + $alwaysIterates = $alwaysIterates->and($this->readStoredOrPriceOnDemand($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); } @@ -2206,7 +2212,7 @@ public function processStmtNode( } } - $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + $exhaustive = $condResult->getTypeForScope($scopeForBranches) instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; @@ -2459,7 +2465,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $scope->getType($var->var); + $varType = $this->readStoredOrPriceOnDemand($var->var, $scope); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2590,7 +2596,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } else { $constantName = new Name\FullyQualified($const->name->toString()); } - $scope = $scope->assignExpression(new ConstFetch($constantName), $scope->getType($const->value), $scope->getNativeType($const->value)); + $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getTypeForScope($scope), $constResult->getNativeTypeForScope($scope)); } } elseif ($stmt instanceof Node\Stmt\ClassConst) { $hasYield = false; @@ -2606,8 +2612,8 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } $scope = $scope->assignExpression( new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), - $scope->getType($const->value), - $scope->getNativeType($const->value), + $constResult->getTypeForScope($scope), + $constResult->getNativeTypeForScope($scope), ); } } elseif ($stmt instanceof Node\Stmt\EnumCase) { @@ -2831,12 +2837,12 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { if ($expr instanceof MethodCall) { - $methodCalledOnType = $scope->getType($expr->var); + $methodCalledOnType = $this->readStoredOrPriceOnDemand($expr->var, $scope->toMutatingScope()); } else { if ($expr->class instanceof Name) { $methodCalledOnType = $scope->resolveTypeByName($expr->class); } else { - $methodCalledOnType = $scope->getType($expr->class); + $methodCalledOnType = $this->readStoredOrPriceOnDemand($expr->class, $scope->toMutatingScope()); } } @@ -2869,6 +2875,10 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return $expr; } + // Scope::getType() here memoises the expression's sub-expression types onto + // $scope (e.g. the array-dim-fetches of a `$x[...] ??= []` chain); the ??= + // offset detection relies on that memoised state, so the side-effect-free + // helpers would regress bug-13623. Kept as Scope::getType deliberately. $exprType = $scope->getType($expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { return $expr; @@ -3240,7 +3250,7 @@ private function processClosureNodeInternal( $inAssignRightSideVariableName === $use->var->name && $inAssignRightSideExpr !== null ) { - $inAssignRightSideType = $scope->getType($inAssignRightSideExpr); + $inAssignRightSideType = $this->resolveCallableTypeForScope($inAssignRightSideExpr, $scope); if ($inAssignRightSideType instanceof ClosureType) { $variableType = $inAssignRightSideType; } else { @@ -3251,7 +3261,7 @@ private function processClosureNodeInternal( $variableType = TypeCombinator::union($scope->getVariableType($inAssignRightSideVariableName), $inAssignRightSideType); } } - $inAssignRightSideNativeType = $scope->getNativeType($inAssignRightSideExpr); + $inAssignRightSideNativeType = $this->resolveCallableTypeForScope($inAssignRightSideExpr, $scope->doNotTreatPhpDocTypesAsCertain()); if ($inAssignRightSideNativeType instanceof ClosureType) { $variableNativeType = $inAssignRightSideNativeType; } else { @@ -3452,26 +3462,44 @@ public function processArrowFunctionNode( * @param Node\Arg[]|null $args * @return ParameterReflection[]|null */ - public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType): ?array + public function createCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType): ?array { - return $this->doCreateCallableParameters($scope, $closureExpr, $args, $passedToType, static fn (Scope $s, Expr $e) => $s->getType($e)); + return $this->doCreateCallableParameters($scope, $closureExpr, $args, $passedToType, fn (MutatingScope $s, Expr $e): Type => $this->resolveCallableTypeForScope($e, $s)); } /** * @param Node\Arg[]|null $args * @return ParameterReflection[]|null */ - public function createNativeCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $nativePassedToType): ?array + public function createNativeCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $nativePassedToType): ?array + { + return $this->doCreateCallableParameters($scope, $closureExpr, $args, $nativePassedToType, fn (MutatingScope $s, Expr $e): Type => $this->resolveCallableTypeForScope($e, $s->doNotTreatPhpDocTypesAsCertain())); + } + + /** + * Resolves the type of an expression a callable parameter is derived from - + * either the closure/arrow function whose acceptors describe the parameters, + * or a call argument refining them. A closure/arrow function is resolved + * through its TypeResolvingExprHandler (as Scope::getType() would), not by + * processing it on demand: createCallableParameters() runs while that very + * closure is being processed, so on-demand processing would re-enter + * processClosureNodeInternal() endlessly. + */ + private function resolveCallableTypeForScope(Expr $expr, MutatingScope $scope): Type { - return $this->doCreateCallableParameters($scope, $closureExpr, $args, $nativePassedToType, static fn (Scope $s, Expr $e) => $s->getNativeType($e)); + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr); + } + + return $this->readStoredOrPriceOnDemand($expr, $scope); } /** * @param Node\Arg[]|null $args - * @param Closure(Scope, Expr): Type $typeGetter + * @param Closure(MutatingScope, Expr): Type $typeGetter * @return ParameterReflection[]|null */ - private function doCreateCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType, Closure $typeGetter): ?array + private function doCreateCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType, Closure $typeGetter): ?array { $callableParameters = null; if ($args !== null) { @@ -4032,7 +4060,7 @@ public function processArgs( } $this->storeExpressionResult($storage, $arg->value, $arrowFunctionResult); } else { - $exprType = $scope->getType($arg->value); + $exprType = $this->readStoredOrPriceOnDemand($arg->value, $scope); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); @@ -4142,7 +4170,7 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + $argType = $this->readStoredOrPriceOnDemand($arg->value, $scope); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { @@ -4426,14 +4454,14 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + $this->priceSyntheticOnDemand($variableNode, $scope->doNotTreatPhpDocTypesAsCertain()), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $scope->getType($defaultExpr); + $originalType = $this->readStoredOrPriceOnDemand($defaultExpr, $scope); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -4499,6 +4527,8 @@ private function tryProcessUnrolledConstantArrayForeach( MutatingScope $originalScope, ExpressionResultStorage $originalStorage, StatementContext $context, + Type $iterateeType, + Type $nativeIterateeType, ): ?array { if ($stmt->byRef) { @@ -4511,7 +4541,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $iterateeType = $originalScope->getType($stmt->expr); if (!$iterateeType->isConstantArray()->yes()) { return null; } @@ -4537,7 +4566,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; @@ -4673,7 +4701,7 @@ private function tryProcessUnrolledConstantArrayForeach( $prevLoopScope = $loopScope; $iterStorage = $originalStorage->duplicate(); $iterBodyScope = $loopScope->mergeWith($endScope); - $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $loopScope = $iterBodyScopeResult->getScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -4698,9 +4726,8 @@ private function tryProcessUnrolledConstantArrayForeach( return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys]; } - private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint + private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee, Type $exprType): ?InternalThrowPoint { - $exprType = $scope->getType($iteratee); $traversableType = new ObjectType(Traversable::class); if ($traversableType->isSuperTypeOf($exprType)->no()) { @@ -4732,13 +4759,12 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, callable $nodeCallback): MutatingScope + private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, Type $iterateeType, Type $nativeIterateeType, callable $nodeCallback): MutatingScope { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } - $iterateeType = $originalScope->getType($stmt->expr); if ( ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) @@ -4763,7 +4789,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $stmt->valueVar, new NativeTypeExpr( $originalScope->getIterableValueType($iterateeType), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($nativeIterateeType), ), $nodeCallback, )->getScope(); @@ -4781,7 +4807,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $stmt->keyVar, new NativeTypeExpr( $originalScope->getIterableKeyType($iterateeType), - $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableKeyType($nativeIterateeType), ), $nodeCallback, )->getScope(); @@ -4845,8 +4871,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $arrayArg = $args[0]->value; $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $scope->getType($arrayArg)->getIterableValueType(), - $scope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $scope)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $scope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -5144,7 +5170,7 @@ public function processCalledMethod(MethodReflection $methodReflection): ?Mutati $statementResult = $executionEnd->getStatementResult(); $endNode = $executionEnd->getNode(); if ($endNode instanceof Node\Stmt\Expression) { - $exprType = $statementResult->getScope()->getType($endNode->expr); + $exprType = $this->readStoredOrPriceOnDemand($endNode->expr, $statementResult->getScope()->toMutatingScope()); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5531,12 +5557,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayType = $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -5556,12 +5582,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayType = $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } From b765b60e3057d2522cde1f7ee351b1b2394b7203 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 19:29:19 +0200 Subject: [PATCH 097/378] Correct the explanation of the two load-bearing Scope::getType() exceptions --- src/Analyser/ExprHandler/AssignHandler.php | 14 ++++++++------ src/Analyser/NodeScopeResolver.php | 10 ++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index d0f95ca0285..f1dc944d2ea 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -670,12 +670,14 @@ public function processAssignVar( } // SKIPPED (single-pass inside-out invariant): these two reads must stay as - // Scope::getType()/getNativeType(). Unlike the non-caching helpers, - // Scope::getType() memoises the assigned expression's sub-expression types - // onto $scope (e.g. hasExpressionType() for the array-dim-fetch being - // written). produceArrayDimFetchAssignValueToWrite() below relies on that - // memoised state to keep a freshly-coalesced offset optional - replacing - // these with the helpers regresses bug-13623 ($x[...] ??= [] chains). + // Scope::getType()/getNativeType(). This is NOT a scope-state side effect + // (assignExpression cannot reproduce it): getType() returns its cached + // resolvedTypes value, computed during loop convergence when a `$x[...] ??= + // []` left side was still maybe-set, so the coalesced value keeps its + // optional array{} branch. The side-effect-free helpers re-price on the + // converged (definitely-set) scope, where CoalesceHandler drops the array{} + // branch (issetCheck === true) - which regresses bug-13623. The optionality + // lives in the loop history the converged scope no longer carries. $valueToWrite = $scope->getType($assignedExpr); $nativeValueToWrite = $scope->getNativeType($assignedExpr); $scopeBeforeAssignEval = $scope; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 21eef2223ae..326235c84c6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2875,10 +2875,12 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return $expr; } - // Scope::getType() here memoises the expression's sub-expression types onto - // $scope (e.g. the array-dim-fetches of a `$x[...] ??= []` chain); the ??= - // offset detection relies on that memoised state, so the side-effect-free - // helpers would regress bug-13623. Kept as Scope::getType deliberately. + // Scope::getType() must stay here (not a scope-state side effect): for a + // `$x[...] ??= []` expression it returns getType()'s cached resolvedTypes + // value, computed during loop convergence when the left side was maybe-set + // (so the coalesced value keeps its optional array{} branch). The + // side-effect-free helpers re-price on the converged scope and drop that + // branch, regressing bug-13623. See AssignHandler::processAssignVar. $exprType = $scope->getType($expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { return $expr; From 552b480432d8e05537b6ee12e4547f944176ed9c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 19:34:37 +0200 Subject: [PATCH 098/378] Read Identical operand types from results in RicherScopeGetTypeHelper when called inside-out --- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 ++-- src/Analyser/RicherScopeGetTypeHelper.php | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 3e740ee9de4..297cbb8bb4f 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -159,11 +159,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\Identical) { - return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr)->type; + return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr, $nodeScopeResolver)->type; } if ($expr instanceof BinaryOp\NotIdentical) { - return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr)->type; + return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr, $nodeScopeResolver)->type; } if ($expr instanceof BinaryOp\LogicalXor) { diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c1875809..557f4949469 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -27,7 +27,7 @@ public function __construct( /** * @return TypeResult */ - public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeResolver $nodeScopeResolver = null): TypeResult { if ( $expr->left instanceof Variable @@ -39,8 +39,15 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult return new TypeResult(new ConstantBooleanType(true), []); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // $nodeScopeResolver is passed from inside-out callbacks (e.g. BinaryOp's + // typeCallback) so the operands are read from their ExpressionResults + // instead of Scope::getType(); rules call this without it (BC). + $leftType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()) + : $scope->getType($expr->left); + $rightType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()) + : $scope->getType($expr->right); if ( ( @@ -78,9 +85,9 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult /** * @return TypeResult */ - public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr): TypeResult + public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, ?NodeScopeResolver $nodeScopeResolver = null): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right), $nodeScopeResolver); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); From d47d6d35ce846fca4c148afca848e9b856a21012 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 19:40:32 +0200 Subject: [PATCH 099/378] Read expr types from results in SpecifiedTypes::normalize when called inside-out --- src/Analyser/ExprHandler/BooleanAndHandler.php | 4 ++-- src/Analyser/ExprHandler/BooleanOrHandler.php | 8 ++++---- .../ExprHandler/Helper/EqualityTypeSpecifyingHelper.php | 6 +++--- src/Analyser/ExprHandler/NullsafeMethodCallHandler.php | 4 ++-- .../ExprHandler/NullsafePropertyFetchHandler.php | 4 ++-- src/Analyser/SpecifiedTypes.php | 9 +++++++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 863b6b5c91b..b06378b06c6 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -128,8 +128,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); } else { - $leftNormalized = $leftTypes->normalize($s); - $rightNormalized = $rightTypes->normalize($rightScope); + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); $types = $leftNormalized->intersectWith($rightNormalized); $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 36bd165ed9e..47e0c5d3923 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -180,15 +180,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ( $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() ) { - $types = $rightTypes->normalize($rightScope); + $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); } elseif ( $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() ) { - $types = $leftTypes->normalize($s); + $types = $leftTypes->normalize($s, $nodeScopeResolver); } else { - $leftNormalized = $leftTypes->normalize($s); - $rightNormalized = $rightTypes->normalize($rightScope); + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); $types = $leftNormalized->intersectWith($rightNormalized); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($nodeScopeResolver, $s, $rightScope, $expr, $types); $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index cd02477548d..ee541152646 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -249,7 +249,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ return $context->true() ? $leftTypes->unionWith($rightTypes) - : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($scope)); + : $leftTypes->normalize($scope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($scope, $nodeScopeResolver)); } public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes @@ -757,8 +757,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) - ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope)); + return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 5f7f089a314..e0de6903c97 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -115,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new NullType(), ); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $nodeScopeResolver): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } @@ -130,7 +130,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->setRootExpr($expr); $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s)->intersectWith($nullSafeTypes->normalize($s)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 1a3dd160d41..d7b94e6a7b6 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -92,7 +92,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new NullType(), ); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $nodeScopeResolver): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } @@ -107,7 +107,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->setRootExpr($expr); $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s)->intersectWith($nullSafeTypes->normalize($s)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, ); } diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 5cfa65dc53b..ad213042f35 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -218,13 +218,18 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - public function normalize(Scope $scope): self + public function normalize(Scope $scope, ?NodeScopeResolver $nodeScopeResolver = null): self { $sureTypes = $this->sureTypes; foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { if (!isset($sureTypes[$exprString])) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($exprNode), $sureNotType)]; + // $nodeScopeResolver is passed from inside-out callbacks so the expr + // type is read from its ExpressionResult instead of Scope::getType(). + $exprType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode, $scope->toMutatingScope()) + : $scope->getType($exprNode); + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($exprType, $sureNotType)]; continue; } From 342684c237e6c8bcc28d1c139f18bb863d90032d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 20:31:05 +0200 Subject: [PATCH 100/378] Read assign-target sub-expression types from their results instead of the on-demand helpers in AssignHandler --- src/Analyser/ExprHandler/AssignHandler.php | 34 +++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f1dc944d2ea..c18e89b1b0f 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -604,12 +604,12 @@ public function processAssignVar( if ($enterExpressionAssign) { $scope = $scope->enterExpressionAssign($var, false); } - $result = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $result->hasYield(); - $throwPoints = $result->getThrowPoints(); - $impurePoints = $result->getImpurePoints(); - $isAlwaysTerminating = $result->isAlwaysTerminating(); - $scope = $result->getScope(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + $isAlwaysTerminating = $varResult->isAlwaysTerminating(); + $scope = $varResult->getScope(); if ($enterExpressionAssign) { $scope = $scope->exitExpressionAssign($var); } @@ -690,8 +690,8 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var, $scope); - $varNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($var, $scope); + $varType = $varResult->getTypeForScope($scope); + $varNativeType = $varResult->getNativeTypeForScope($scope); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -819,7 +819,7 @@ public function processAssignVar( $throwPoints[] = InternalThrowPoint::createImplicit($scope, $var); } - $propertyHolderType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope); + $propertyHolderType = $objectResult->getTypeForScope($scope); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); @@ -903,8 +903,8 @@ public function processAssignVar( if ($var->class instanceof Node\Name) { $propertyHolderType = $scope->resolveTypeByName($var->class); } else { - $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); - $propertyHolderType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->class, $scope); + $classResult = $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); + $propertyHolderType = $classResult->getTypeForScope($scope); } $propertyName = null; @@ -1035,21 +1035,21 @@ public function processAssignVar( // the chain is usually a clone of AST nodes already processed elsewhere // (see Unset_ handling) - process it with a noop callback so that // results for its nodes are stored without invoking rules twice - $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $offsetTypes = []; $offsetNativeTypes = []; foreach (array_reverse($dimFetchStack) as $dimFetch) { $dimExpr = $dimFetch->getDim(); - $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $offsetTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope), $dimFetch]; - $offsetNativeTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope), $dimFetch]; + $dimResult = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $offsetTypes[] = [$dimResult->getTypeForScope($scope), $dimFetch]; + $offsetNativeTypes[] = [$dimResult->getNativeTypeForScope($scope), $dimFetch]; } $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope); - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var, $scope); - $varNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($var, $scope); + $varType = $varResult->getTypeForScope($scope); + $varNativeType = $varResult->getNativeTypeForScope($scope); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; From be8ffd5c142bbf9f6c1ef8524f7b09da17c842ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 20:34:00 +0200 Subject: [PATCH 101/378] Read while-loop condition type from its result instead of the on-demand helper --- src/Analyser/NodeScopeResolver.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 326235c84c6..1245824c2bd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1862,14 +1862,15 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $bodyCondResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $bodyCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScopeMaybeRan) : $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScopeMaybeRan->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyCondResult->getTypeForScope($bodyScopeMaybeRan) : $bodyCondResult->getTypeForScope($bodyScopeMaybeRan->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } From 1491f9ff1ebebee6bc2b2aefefeaa00af26c0f83 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 20:51:16 +0200 Subject: [PATCH 102/378] Pass already-computed results into ImplicitToStringCallHelper and read operand types directly in BinaryOp/Match --- src/Analyser/ExprHandler/BinaryOpHandler.php | 45 +++++++++++++------ .../ExprHandler/CastStringHandler.php | 2 +- .../Helper/ImplicitToStringCallHelper.php | 15 +++++-- .../ExprHandler/InterpolatedStringHandler.php | 2 +- src/Analyser/ExprHandler/MatchHandler.php | 4 +- src/Analyser/ExprHandler/PrintHandler.php | 2 +- src/Analyser/NodeScopeResolver.php | 2 +- 7 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 297cbb8bb4f..e48728343e1 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -101,8 +101,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->left, $scope); - $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->right, $leftResult->getScope()); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->left, $scope, $leftResult); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->right, $leftResult->getScope(), $rightResult); $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } @@ -116,12 +116,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr, $nodeScopeResolver): Type { - // the operands were processed during processExpr; read their stored - // results instead of re-walking via Scope::getType(). Synthetic - // nodes the resolver builds (e.g. getDivType's Mod) are priced on - // demand by the same helper. - $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + typeCallback: function (MutatingScope $scope) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): Type { + // the operands were processed during processExpr; read their already + // computed results instead of re-walking via Scope::getType(). + // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are + // priced on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver): Type { + if ($e === $expr->left) { + return $leftResult->getTypeForScope($scope); + } + if ($e === $expr->right) { + return $rightResult->getTypeForScope($scope); + } + + return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + }; if ($expr instanceof BinaryOp\Smaller) { return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); @@ -236,7 +245,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { if ($expr instanceof BinaryOp\Identical) { return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context); } @@ -308,10 +317,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; $offset = $orEqual ? 0 : 1; - // the operands and their subexpressions were processed during - // processExpr; read their stored results instead of re-walking - // via Scope::getType(). - $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + // the operands were processed during processExpr; read their + // already computed results instead of re-walking via + // Scope::getType(). Their subexpressions (e.g. count() arguments) + // were also processed and are read from the stored result. + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver): Type { + if ($e === $expr->left) { + return $leftResult->getTypeForScope($scope); + } + if ($e === $expr->right) { + return $rightResult->getTypeForScope($scope); + } + + return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + }; $leftType = $getType($expr->left); $result = (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 0b6d874814d..785458350e4 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope, $exprResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index d98ff8b4c49..5a2a1ee138b 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -26,14 +26,21 @@ public function __construct( { } - public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver, Expr $expr, MutatingScope $scope): ExpressionResult + /** + * @param ExpressionResult|null $exprResult the already-computed result of $expr, + * passed by callers that processed it on $scope so this helper reads its type + * directly instead of re-walking via Scope::getType(); callers that do not + * hold the result (only the Expr) pass null and the type is read from the + * stored result or priced on demand + */ + public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver, Expr $expr, MutatingScope $scope, ?ExpressionResult $exprResult = null): ExpressionResult { $throwPoints = []; $impurePoints = []; - // the expression was processed before this call; read its stored result - // or price it on demand instead of re-walking via Scope::getType(). - $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + $exprType = $exprResult !== null + ? $exprResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $toStringMethod = null; if (!$exprType->isObject()->no()) { diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 98d5d7868cb..577938afa3f 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -63,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $part, $scope, $partResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 7f9fcf7c026..047d0acc6cf 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -497,9 +497,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond; if (!$isExhaustive) { - // the subject was processed above; read its stored result on the + // the subject was processed above ($condResult); read its type on the // arm-narrowed scope instead of re-walking via Scope::getType(). - $remainingType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->cond, $matchScope); + $remainingType = $condResult->getTypeForScope($matchScope); if ($remainingType instanceof NeverType) { $isExhaustive = true; } diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 95b8b2dc76d..8bee2ebdb2d 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -48,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $expr->expr, $scope, $exprResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1245824c2bd..f9b61765007 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1161,7 +1161,7 @@ public function processStmtNode( $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($this, $echoExpr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($this, $echoExpr, $scope, $result); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); From 3a803ff734f5697b8a7e871025fd9377ba7a6547 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 18 Jun 2026 21:04:30 +0200 Subject: [PATCH 103/378] NSRT test for precise Scope --- .../nsrt/precise-scope-select-from-args.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php diff --git a/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php b/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php new file mode 100644 index 00000000000..989c34a46b3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php @@ -0,0 +1,28 @@ + Date: Thu, 18 Jun 2026 22:18:47 +0200 Subject: [PATCH 104/378] Extract intrinsic argument parameter overrides from selectFromArgs into a pluggable applyIntrinsicArgOverrides --- src/Reflection/ParametersAcceptorSelector.php | 276 ++++++++++-------- 1 file changed, 158 insertions(+), 118 deletions(-) diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 24751d5f65f..a5198889c59 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -4,6 +4,7 @@ use Closure; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; @@ -80,6 +81,124 @@ public static function selectFromArgs( { $types = []; $unpack = false; + $parametersAcceptors = self::applyIntrinsicArgOverrides( + $args, + $parametersAcceptors, + $namedArgumentsVariants, + $scope, + static fn (Expr $e): Type => $scope->getType($e), + static fn (Expr $e): Type => $scope->getNativeType($e), + static fn (Type $t): Type => $scope->getIterableValueType($t), + static fn (Type $t): Type => $scope->getIterableKeyType($t), + ); + + if (count($parametersAcceptors) === 1) { + $acceptor = $parametersAcceptors[0]; + if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) { + return $acceptor; + } + } + + $reorderedArgs = $args; + $parameters = null; + $singleParametersAcceptor = null; + if (count($parametersAcceptors) === 1) { + if (!array_is_list($args)) { + // actually $args parameter should be typed to list but we can't atm, + // because its a BC break. + $args = array_values($args); + } + $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptors[0], $args); + $singleParametersAcceptor = $parametersAcceptors[0]; + } + + $hasName = false; + foreach ($reorderedArgs ?? $args as $i => $arg) { + $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $parameter = null; + if ($singleParametersAcceptor !== null) { + $parameters = $singleParametersAcceptor->getParameters(); + if (isset($parameters[$i])) { + $parameter = $parameters[$i]; + } elseif (count($parameters) > 0 && $singleParametersAcceptor->isVariadic()) { + $parameter = array_last($parameters); + } + } + + if ($parameter !== null && $scope instanceof MutatingScope) { + $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; + $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); + } + + $type = $scope->getType($originalArg->value); + + if ($parameter !== null && $scope instanceof MutatingScope) { + $scope = $scope->popInFunctionCall(); + } + + if ($originalArg->name !== null) { + $index = $originalArg->name->toString(); + $hasName = true; + } else { + $index = $i; + } + if ($originalArg->unpack) { + $unpack = true; + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + foreach ($constantArrays as $constantArray) { + $values = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $valueType = $values[$j]; + $valueIndex = $keyType->getValue(); + if (is_string($valueIndex)) { + $hasName = true; + } else { + $valueIndex = $i + $j; + } + + $types[$valueIndex] = isset($types[$valueIndex]) + ? TypeCombinator::union($types[$valueIndex], $valueType) + : $valueType; + } + } + } else { + $types[$index] = $type->getIterableValueType(); + } + } else { + $types[$index] = $type; + } + } + + if ($hasName && $namedArgumentsVariants !== null) { + return self::selectFromTypes($types, $namedArgumentsVariants, $unpack); + } + + return self::selectFromTypes($types, $parametersAcceptors, $unpack); + } + + /** + * @internal + * @param Node\Arg[] $args + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants + * @param Closure(Expr): Type $typeGetter + * @param Closure(Expr): Type $nativeTypeGetter + * @param Closure(Type): Type $iterableValueTypeGetter + * @param Closure(Type): Type $iterableKeyTypeGetter + * @return ParametersAcceptor[] + */ + public static function applyIntrinsicArgOverrides( + array $args, + array $parametersAcceptors, + ?array $namedArgumentsVariants, + Scope $scope, + Closure $typeGetter, + Closure $nativeTypeGetter, + Closure $iterableValueTypeGetter, + Closure $iterableKeyTypeGetter, + ): array + { if ( count($args) > 0 && count($parametersAcceptors) > 0 @@ -89,15 +208,15 @@ public static function selectFromArgs( $callbackParameters = []; $nativeCallbackParameters = []; foreach ($arrayMapArgs as $arg) { - $argType = $scope->getType($arg->value); - $nativeArgType = $scope->getNativeType($arg->value); + $argType = ($typeGetter)($arg->value); + $nativeArgType = ($nativeTypeGetter)($arg->value); if ($arg->unpack) { $constantArrays = $argType->getConstantArrays(); if (count($constantArrays) > 0) { foreach ($constantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } } @@ -106,13 +225,13 @@ public static function selectFromArgs( foreach ($nativeConstantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $nativeCallbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } } } else { - $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } @@ -133,7 +252,7 @@ public static function selectFromArgs( } if (count($args) >= 3 && (bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { - $optType = $scope->getType($args[1]->value); + $optType = ($typeGetter)($args[1]->value); $valueTypes = []; foreach ($optType->getConstantScalarValues() as $scalarValue) { @@ -177,7 +296,7 @@ public static function selectFromArgs( } if (count($args) >= 2 && (bool) $args[1]->getAttribute(CurlSetOptArrayArgVisitor::ATTRIBUTE_NAME)) { - $optArrayType = $scope->getType($args[1]->value); + $optArrayType = ($typeGetter)($args[1]->value); $hasTypes = false; $builder = ConstantArrayTypeBuilder::createEmpty(); @@ -232,23 +351,23 @@ public static function selectFromArgs( $arrayFilterParameters = null; $nativeArrayFilterParameters = null; if (isset($args[2])) { - $mode = $scope->getType($args[2]->value); + $mode = ($typeGetter)($args[2]->value); if ($mode instanceof ConstantIntegerType) { if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { $arrayFilterParameters = [ - new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayFilterParameters = [ - new DummyParameter('key', $scope->getIterableKeyType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { $arrayFilterParameters = [ - new DummyParameter('item', $scope->getIterableValueType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayFilterParameters = [ - new DummyParameter('item', $scope->getIterableValueType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; } } @@ -257,22 +376,22 @@ public static function selectFromArgs( $acceptor = $parametersAcceptors[0]; $parameters = $acceptor->getParameters(); if (isset($parameters[1])) { - $arrayArgType = $scope->getType($args[0]->value); + $arrayArgType = ($typeGetter)($args[0]->value); $callableType = new UnionType([ new CallableType( $arrayFilterParameters ?? [ - new DummyParameter('item', $scope->getIterableValueType($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, ), new NullType(), ]); - $nativeArrayArgType = $scope->getNativeType($args[0]->value); + $nativeArrayArgType = ($nativeTypeGetter)($args[0]->value); $nativeCallableType = new UnionType([ new CallableType( $nativeArrayFilterParameters ?? [ - new DummyParameter('item', $scope->getIterableValueType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, @@ -314,19 +433,19 @@ public static function selectFromArgs( } if ((bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { - $arrayArgType = $scope->getType($args[0]->value); - $nativeArrayArgType = $scope->getNativeType($args[0]->value); + $arrayArgType = ($typeGetter)($args[0]->value); + $nativeArrayArgType = ($nativeTypeGetter)($args[0]->value); $arrayWalkParameters = [ - new DummyParameter('item', $scope->getIterableValueType($arrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayWalkParameters = [ - new DummyParameter('item', $scope->getIterableValueType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; if (isset($args[2])) { - $arrayWalkParameters[] = new DummyParameter('arg', $scope->getType($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeArrayWalkParameters[] = new DummyParameter('arg', $scope->getNativeType($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $arrayWalkParameters[] = new DummyParameter('arg', ($typeGetter)($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeArrayWalkParameters[] = new DummyParameter('arg', ($nativeTypeGetter)($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } $acceptor = $parametersAcceptors[0]; @@ -343,20 +462,20 @@ public static function selectFromArgs( $acceptor = $parametersAcceptors[0]; $parameters = $acceptor->getParameters(); if (isset($parameters[1])) { - $argType = $scope->getType($args[0]->value); + $argType = ($typeGetter)($args[0]->value); $callableType = new CallableType( [ - new DummyParameter('value', $scope->getIterableValueType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('value', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, ); - $nativeArgType = $scope->getNativeType($args[0]->value); + $nativeArgType = ($nativeTypeGetter)($args[0]->value); $nativeCallableType = new CallableType( [ - new DummyParameter('value', $scope->getIterableValueType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('value', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, @@ -371,7 +490,7 @@ public static function selectFromArgs( $closureBindToVar instanceof Node\Expr\Variable && is_string($closureBindToVar->name) ) { - $varType = $scope->getType($closureBindToVar); + $varType = ($typeGetter)($closureBindToVar); if ((new ObjectType(Closure::class))->isSuperTypeOf($varType)->yes()) { $inFunction = $scope->getFunction(); if ($inFunction !== null) { @@ -458,92 +577,13 @@ public static function selectFromArgs( } } - if (count($parametersAcceptors) === 1) { - $acceptor = $parametersAcceptors[0]; - if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) { - return $acceptor; - } - } - - $reorderedArgs = $args; - $parameters = null; - $singleParametersAcceptor = null; - if (count($parametersAcceptors) === 1) { - if (!array_is_list($args)) { - // actually $args parameter should be typed to list but we can't atm, - // because its a BC break. - $args = array_values($args); - } - $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptors[0], $args); - $singleParametersAcceptor = $parametersAcceptors[0]; - } - - $hasName = false; - foreach ($reorderedArgs ?? $args as $i => $arg) { - $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; - $parameter = null; - if ($singleParametersAcceptor !== null) { - $parameters = $singleParametersAcceptor->getParameters(); - if (isset($parameters[$i])) { - $parameter = $parameters[$i]; - } elseif (count($parameters) > 0 && $singleParametersAcceptor->isVariadic()) { - $parameter = array_last($parameters); - } - } - - if ($parameter !== null && $scope instanceof MutatingScope) { - $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; - $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); - } - - $type = $scope->getType($originalArg->value); - - if ($parameter !== null && $scope instanceof MutatingScope) { - $scope = $scope->popInFunctionCall(); - } - - if ($originalArg->name !== null) { - $index = $originalArg->name->toString(); - $hasName = true; - } else { - $index = $i; - } - if ($originalArg->unpack) { - $unpack = true; - $constantArrays = $type->getConstantArrays(); - if (count($constantArrays) > 0) { - foreach ($constantArrays as $constantArray) { - $values = $constantArray->getValueTypes(); - foreach ($constantArray->getKeyTypes() as $j => $keyType) { - $valueType = $values[$j]; - $valueIndex = $keyType->getValue(); - if (is_string($valueIndex)) { - $hasName = true; - } else { - $valueIndex = $i + $j; - } - - $types[$valueIndex] = isset($types[$valueIndex]) - ? TypeCombinator::union($types[$valueIndex], $valueType) - : $valueType; - } - } - } else { - $types[$index] = $type->getIterableValueType(); - } - } else { - $types[$index] = $type; - } - } - - if ($hasName && $namedArgumentsVariants !== null) { - return self::selectFromTypes($types, $namedArgumentsVariants, $unpack); - } - - return self::selectFromTypes($types, $parametersAcceptors, $unpack); + return $parametersAcceptors; } - private static function hasAcceptorTemplateOrLateResolvableType(ParametersAcceptor $acceptor): bool + /** + * @internal + */ + public static function hasAcceptorTemplateOrLateResolvableType(ParametersAcceptor $acceptor): bool { if ($acceptor->getReturnType()->hasTemplateOrLateResolvableType()) { return true; From 14c06c7891f42b749cef8246c787cf2cf4a37398 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 08:15:33 +0200 Subject: [PATCH 105/378] Resolve argument types on an arg-to-arg evolving scope and select the parameters acceptor via selectFromTypes in processArgs --- src/Analyser/ArgsResult.php | 61 ++++ src/Analyser/ExprHandler/FuncCallHandler.php | 35 ++- .../Helper/MethodCallReturnTypeHelper.php | 4 +- .../ExprHandler/MethodCallHandler.php | 33 ++- src/Analyser/ExprHandler/NewHandler.php | 31 ++- .../ExprHandler/StaticCallHandler.php | 16 +- src/Analyser/MutatingScope.php | 14 + src/Analyser/NodeScopeResolver.php | 262 +++++++++++++++--- src/Reflection/ParametersAcceptorSelector.php | 51 ++++ 9 files changed, 449 insertions(+), 58 deletions(-) create mode 100644 src/Analyser/ArgsResult.php diff --git a/src/Analyser/ArgsResult.php b/src/Analyser/ArgsResult.php new file mode 100644 index 00000000000..217cccecf5d --- /dev/null +++ b/src/Analyser/ArgsResult.php @@ -0,0 +1,61 @@ +expressionResult->getScope(); + } + + public function hasYield(): bool + { + return $this->expressionResult->hasYield(); + } + + public function isAlwaysTerminating(): bool + { + return $this->expressionResult->isAlwaysTerminating(); + } + + /** + * @return InternalThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->expressionResult->getThrowPoints(); + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->expressionResult->getImpurePoints(); + } + + public function getResolvedParametersAcceptor(): ?ParametersAcceptor + { + return $this->resolvedParametersAcceptor; + } + +} diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index ed893997763..156d71f3a49 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -111,6 +111,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $parametersAcceptor = null; + $variants = []; + $namedArgumentsVariants = null; $functionReflection = null; $throwPoints = []; $impurePoints = []; @@ -118,10 +120,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->name instanceof Expr) { $nameType = $scope->getType($expr->name); if (!$nameType->isCallable()->no()) { + $variants = $nameType->getCallableParametersAcceptors($scope); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), - $nameType->getCallableParametersAcceptors($scope), + $variants, null, ); } @@ -149,11 +152,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $variants = $functionReflection->getVariants(); + $namedArgumentsVariants = $functionReflection->getNamedArgumentsVariants(); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), - $functionReflection->getVariants(), - $functionReflection->getNamedArgumentsVariants(), + $variants, + $namedArgumentsVariants, ); $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); if ($impurePoint !== null) { @@ -277,7 +282,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $scopeBeforeArgs = $scope; - $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); + $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); + $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $argsResult->hasYield(); @@ -597,6 +603,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: $resolvedParametersAcceptor !== null + ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) + : null, ); } @@ -805,6 +814,20 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra } public function resolveType(MutatingScope $scope, Expr $expr): Type + { + return $this->resolveReturnType($scope, $expr, null); + } + + /** + * The stored call-expression type is derived from $preResolvedAcceptor - the + * acceptor processArgs() selected from the arg types gathered on the arg-to-arg + * evolving scope (type-driven, generics resolved). When null (on-demand / + * synthetic pricing, or special cases below), it falls back to selecting from + * the args on the asking scope. + * + * @param FuncCall $expr + */ + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type { if ($expr->name instanceof Expr) { $calledOnType = $scope->getType($expr->name); @@ -812,7 +835,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new ErrorType(); } - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), $calledOnType->getCallableParametersAcceptors($scope), @@ -871,7 +894,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } } - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), $functionReflection->getVariants(), diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index da565b78d8d..b34386598ce 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -29,6 +30,7 @@ public function methodCallReturnType( Type $typeWithMethod, string $methodName, MethodCall|Expr\StaticCall $methodCall, + ?ParametersAcceptor $preResolvedAcceptor = null, ): ?Type { $typeWithMethod = $scope->filterTypeWithMethod($typeWithMethod, $methodName); @@ -37,7 +39,7 @@ public function methodCallReturnType( } $methodReflection = $typeWithMethod->getMethod($methodName, $scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $methodReflection->getVariants(), diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 04742be2801..91f058c444b 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -31,6 +31,7 @@ use PHPStan\Node\InvalidateExprNode; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ExtendedParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ErrorType; @@ -97,17 +98,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); } $parametersAcceptor = null; + $variants = []; + $namedArgumentsVariants = null; $methodReflection = null; $calledOnType = $scope->getType($expr->var); if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); if ($methodReflection !== null) { + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), + $variants, + $namedArgumentsVariants, ); } @@ -143,13 +148,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $stmt, $methodReflection, $methodReflection !== null ? $scope->getNakedMethod($calledOnType, $methodReflection->getName()) : null, - $parametersAcceptor, + $variants, + $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, ); + $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); @@ -202,6 +209,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + $typeCallback = $resolvedParametersAcceptor !== null + ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) + : null; + $result = $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -211,6 +222,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, ); $calledOnType = $originalScope->getType($expr->var); @@ -239,6 +251,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), + typeCallback: $typeCallback, ); } } @@ -247,6 +260,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } public function resolveType(MutatingScope $scope, Expr $expr): Type + { + return $this->resolveReturnType($scope, $expr, null); + } + + /** + * The stored call-expression type is derived from $preResolvedAcceptor - the + * acceptor processArgs() selected from the arg types gathered on the arg-to-arg + * evolving scope. Null falls back to re-selecting from the args on the asking + * scope (on-demand / synthetic pricing). + * + * @param MethodCall $expr + */ + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type { if ($expr->name instanceof Identifier) { if ($scope->nativeTypesPromoted) { @@ -268,6 +294,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $scope->getType($expr->var), $expr->name->name, $expr, + $preResolvedAcceptor, ); if ($returnType === null) { $returnType = new ErrorType(); diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index ddd4ed5bfc4..67e66eacee9 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -36,6 +36,7 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Dummy\DummyConstructorReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; @@ -202,7 +203,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - $argsResult = $nodeScopeResolver->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context); + $variants = $constructorReflection !== null ? $constructorReflection->getVariants() : []; + $namedArgumentsVariants = $constructorReflection !== null ? $constructorReflection->getNamedArgumentsVariants() : null; + $argsResult = $nodeScopeResolver->processArgs($stmt, $constructorReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context); + $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $hasYield || $argsResult->hasYield(); @@ -236,11 +240,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: $resolvedParametersAcceptor !== null && $expr->class instanceof Name + ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) + : null, ); } /** - * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} + * @return array{?ExtendedMethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic): array { @@ -338,9 +345,23 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio } public function resolveType(MutatingScope $scope, Expr $expr): Type + { + return $this->resolveReturnType($scope, $expr, null); + } + + /** + * The stored new-expression type is derived from $preResolvedAcceptor - the + * constructor acceptor processArgs() selected from the arg types gathered on + * the arg-to-arg evolving scope (resolves the class's @template parameters + * from constructor args). Null falls back to re-selecting from the args on the + * asking scope (on-demand / synthetic pricing). + * + * @param New_ $expr + */ + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type { if ($expr->class instanceof Name) { - return $this->exactInstantiation($scope, $expr, $expr->class); + return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor); } if ($expr->class instanceof Node\Stmt\Class_) { $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); @@ -352,7 +373,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return $exprType->getObjectTypeOrClassStringObjectType(); } - private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className): Type + private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor): Type { $resolvedClassName = $scope->resolveName($className); $isStatic = false; @@ -398,7 +419,7 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas $node->getArgs(), ); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $constructorMethod->getVariants(), diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index cedeed87591..d68507dbb6d 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -99,6 +99,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $parametersAcceptor = null; + $variants = []; + $namedArgumentsVariants = null; $methodReflection = null; $closureBindScope = null; if ($expr->name instanceof Identifier) { @@ -107,11 +109,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $methodName = $expr->name->name; if ($classType->hasMethod($methodName)->yes()) { $methodReflection = $classType->getMethod($methodName, $scope); + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), + $variants, + $namedArgumentsVariants, ); $declaringClass = $methodReflection->getDeclaringClass(); @@ -164,11 +168,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), + $variants, + $namedArgumentsVariants, ); } } @@ -217,7 +223,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $returnType = $parametersAcceptor->getReturnType(); $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } - $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); + $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $scopeFunction = $scope->getFunction(); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 82eb3e87e40..c17ea2edff1 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1094,6 +1094,20 @@ private function resolveType(string $exprString, Expr $node): Type $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); if ($exprHandler !== null) { if ($exprHandler instanceof TypeResolvingExprHandler) { + // A call handler that processed this node wires a typeCallback onto + // its stored ExpressionResult carrying the acceptor resolved from + // the arg types gathered on the arg-to-arg evolving scope. Prefer it + // over resolveType(), whose own re-selection would lose generics + // inferred from sibling args. resolveType() still answers synthetic / + // not-yet-processed nodes. + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $result = $storage->findExpressionResult($node); + if ($result !== null && $result->hasTypeCallback()) { + return $result->getTypeForScope($this->toMutatingScope()); + } + } + return $exprHandler->resolveType($this, $node); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f9b61765007..38570101d27 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3638,7 +3638,7 @@ private function processAttributeGroups( ); $expr = new New_($attr->name, $attr->args); $expr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; - $this->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->processArgs($stmt, $constructorReflection, null, $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants(), $expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $this->callNodeCallback($nodeCallback, $attr, $scope, $storage); continue; } @@ -3826,26 +3826,61 @@ private function resolveClosureThisType( /** * @param MethodReflection|FunctionReflection|null $calleeReflection + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants * @param callable(Node $node, Scope $scope): void $nodeCallback */ public function processArgs( Node\Stmt $stmt, $calleeReflection, ?ExtendedMethodReflection $nakedMethodReflection, - ?ParametersAcceptor $parametersAcceptor, + array $parametersAcceptors, + ?array $namedArgumentsVariants, CallLike $callLike, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, ?MutatingScope $closureBindScope = null, - ): ExpressionResult + ): ArgsResult { $args = $callLike->getArgs(); - $parameters = null; - if ($parametersAcceptor !== null) { - $parameters = $parametersAcceptor->getParameters(); + // Evolving-scope arg types: gathered as each argument is processed on the + // scope that evolves arg-to-arg. They select the FINAL resolved acceptor + // (the call's return type, by-ref OUT types), which type-resolves generics + // from the actual argument types. + $gatheredTypes = []; + $gatheredUnpack = false; + $gatheredHasName = false; + + // Metadata acceptor: drives the per-arg by-ref/variadic matching. Selected + // ONCE from all arg types gathered on the initial scope (mirrors the + // original ParametersAcceptorSelector::selectFromArgs()), so multi-variant + // selection - which depends on the total argument count - is stable across + // the per-arg loop rather than flapping as the prefix grows. + $metadataAcceptor = null; + if ($parametersAcceptors !== []) { + $fastPath = count($parametersAcceptors) === 1 + && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($parametersAcceptors[0]) + && !ParametersAcceptorSelector::argsHaveIntrinsicArgOverride($args); + if ($fastPath) { + $metadataAcceptor = $parametersAcceptors[0]; + } else { + $metadataTypes = []; + $metadataUnpack = false; + $metadataHasName = false; + foreach ($args as $i => $arg) { + $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { + $argType = $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope); + } else { + $argType = $this->readStoredOrPriceOnDemand($arg->value, $scope); + } + $this->addGatheredArgType($metadataTypes, $metadataUnpack, $metadataHasName, $originalArg, $i, $argType); + } + $metadataAcceptor = $this->selectArgsMetadataAcceptor($args, $metadataTypes, $parametersAcceptors, $namedArgumentsVariants, $metadataHasName, $metadataUnpack, $scope); + } } $hasYield = false; @@ -3858,33 +3893,50 @@ public function processArgs( $deferredByRefClosureResults = []; $processingOrder = array_keys($args); - $hasReorderedArgs = false; - foreach ($args as $arg) { - if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { - $hasReorderedArgs = true; - break; + usort($processingOrder, static function (int $a, int $b) use ($args): int { + $aOriginalArg = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $bOriginalArg = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $aValue = $aOriginalArg !== null ? $aOriginalArg->value : $args[$a]->value; + $bValue = $bOriginalArg !== null ? $bOriginalArg->value : $args[$b]->value; + $aIsClosure = $aValue instanceof Expr\Closure || $aValue instanceof Expr\ArrowFunction; + $bIsClosure = $bValue instanceof Expr\Closure || $bValue instanceof Expr\ArrowFunction; + if ($aIsClosure !== $bIsClosure) { + // closures sort after non-closures so every sibling feeding an + // intrinsic override / generic callable(T) is in scope first + return $aIsClosure ? 1 : -1; } - } - if ($hasReorderedArgs) { - usort($processingOrder, static function (int $a, int $b) use ($args): int { - $aOriginal = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); - $bOriginal = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); - if ($aOriginal === null && $bOriginal === null) { - return $a <=> $b; - } - if ($aOriginal === null) { - return 1; - } - if ($bOriginal === null) { - return -1; - } - return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); - }); - } + $aOriginal = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $bOriginal = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + if ($aOriginal === null && $bOriginal === null) { + return $a <=> $b; + } + if ($aOriginal === null) { + return 1; + } + if ($bOriginal === null) { + return -1; + } + + return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); + }); foreach ($processingOrder as $i) { $arg = $args[$i]; + + if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { + // Gather the closure/arrow type for the FINAL resolved acceptor on + // the evolving scope, BEFORE the body is processed with a possibly + // generic-resolved parameter injected, so the inferred return type + // stays faithful to the closure's own declaration and its own + // contribution (a TValue from its return) participates in the final + // resolution (see gatherClosureArgType()). + $originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)); + } + + $parameters = $metadataAcceptor?->getParameters(); + $assignByReference = false; $parameter = null; $parameterType = null; @@ -3910,7 +3962,7 @@ public function processArgs( $parameterNativeType = $matchedParameter->getNativeType(); } $parameter = $matchedParameter; - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + } elseif (count($parameters) > 0 && $metadataAcceptor->isVariadic()) { $lastParameter = array_last($parameters); $assignByReference = $lastParameter->passedByReference()->createsNewVariable(); $parameterType = $lastParameter->getType(); @@ -4094,6 +4146,8 @@ public function processArgs( } } } + + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $exprResult->getTypeForScope($scope)); } if ($assignByReference && $lookForUnset) { @@ -4119,14 +4173,27 @@ public function processArgs( $scope = $deferredClosureResult->applyByRefUseScope($scope); } - if ($parameters !== null) { + // Type-driven resolved acceptor: the arg types gathered on the evolving + // scope select (and generic-resolve) the acceptor that drives the call's + // return type. Intrinsic overrides are applied on the final scope, + // mirroring the original selectFromArgs(). + $resolvedAcceptor = null; + if ($parametersAcceptors !== []) { + $resolvedAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); + } + + // The by-ref OUT writeback reads the metadata acceptor: it is selected from + // the full argument count (stable variant) and its parameters are already + // generic-resolved, so OUT types are correct without re-flapping the variant. + $writebackParameters = $metadataAcceptor?->getParameters(); + if ($writebackParameters !== null) { foreach ($args as $i => $arg) { $assignByReference = false; $currentParameter = null; - if (isset($parameters[$i])) { - $currentParameter = $parameters[$i]; - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { - $currentParameter = array_last($parameters); + if (isset($writebackParameters[$i])) { + $currentParameter = $writebackParameters[$i]; + } elseif (count($writebackParameters) > 0 && $metadataAcceptor->isVariadic()) { + $currentParameter = array_last($writebackParameters); } if ($currentParameter !== null) { @@ -4177,11 +4244,12 @@ public function processArgs( if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { - $nakedParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $args, + $nakedParametersAcceptor = $this->selectArgsAcceptor( + $gatheredTypes, $nakedMethodReflection->getVariants(), $nakedMethodReflection->getNamedArgumentsVariants(), + $gatheredHasName, + $gatheredUnpack, ); $nakedReturnType = $nakedParametersAcceptor->getReturnType(); } @@ -4202,7 +4270,125 @@ public function processArgs( } // not storing this, it's scope after processing all args - return $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return new ArgsResult( + $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints), + $resolvedAcceptor, + ); + } + + /** + * Ports the gather-keying of ParametersAcceptorSelector::selectFromArgs(): + * indexes the gathered arg type by name (sets $hasName) vs position, and + * expands unpacked constant arrays / falls back to the iterable value type + * (sets $unpack), so selectFromTypes() picks the matching variant. + * + * @param array $types + */ + private function addGatheredArgType(array &$types, bool &$unpack, bool &$hasName, Node\Arg $originalArg, int $i, Type $type): void + { + if ($originalArg->name !== null) { + $index = $originalArg->name->toString(); + $hasName = true; + } else { + $index = $i; + } + + if ($originalArg->unpack) { + $unpack = true; + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + foreach ($constantArrays as $constantArray) { + $values = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $valueType = $values[$j]; + $valueIndex = $keyType->getValue(); + if (is_string($valueIndex)) { + $hasName = true; + } else { + $valueIndex = $i + $j; + } + + $types[$valueIndex] = isset($types[$valueIndex]) + ? TypeCombinator::union($types[$valueIndex], $valueType) + : $valueType; + } + } + } else { + $types[$index] = $type->getIterableValueType(); + } + } else { + $types[$index] = $type; + } + } + + /** + * Resolves the type of a closure/arrow function argument for the generic + * gather, mirroring ParametersAcceptorSelector::selectFromArgs(): the closure + * type is read with the RAW (un-generic-resolved) acceptor parameter pushed + * onto the in-function-call stack, so its body sees the template parameter + * (effectively mixed for an untyped param) rather than a parameter already + * resolved from sibling args. That keeps the inferred return type (the U in + * callable(T): U) faithful to the closure's own declaration. + * + * @param ParametersAcceptor[] $parametersAcceptors + */ + private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $closureExpr, MutatingScope $scope): Type + { + $rawParameter = null; + if (count($parametersAcceptors) === 1) { + $rawParameters = $parametersAcceptors[0]->getParameters(); + if (isset($rawParameters[$i])) { + $rawParameter = $rawParameters[$i]; + } elseif (count($rawParameters) > 0 && $parametersAcceptors[0]->isVariadic()) { + $rawParameter = array_last($rawParameters); + } + } + + if ($rawParameter !== null) { + $scope = $scope->pushInFunctionCall(null, $rawParameter, false); + } + + return $this->resolveCallableTypeForScope($closureExpr, $scope); + } + + /** + * @param array $types + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function selectArgsAcceptor(array $types, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack): ParametersAcceptor + { + return $hasName && $namedArgumentsVariants !== null + ? ParametersAcceptorSelector::selectFromTypes($types, $namedArgumentsVariants, $unpack) + : ParametersAcceptorSelector::selectFromTypes($types, $parametersAcceptors, $unpack); + } + + /** + * Applies the intrinsic argument overrides (array_map/filter/walk/find, + * curl_setopt, implode, Closure::bind) on the arg-to-arg evolved scope via + * the non-reprocessing readers, then type-selects the metadata acceptor over + * the arg types gathered so far. The overrides read sibling arg types - which + * closures-last ordering keeps in scope/$gatheredTypes before any closure. + * + * @param Node\Arg[] $args + * @param array $gatheredTypes + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function selectArgsMetadataAcceptor(array $args, array $gatheredTypes, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack, MutatingScope $scope): ParametersAcceptor + { + $overridden = ParametersAcceptorSelector::applyIntrinsicArgOverrides( + $args, + $parametersAcceptors, + $namedArgumentsVariants, + $scope, + fn (Expr $e): Type => $this->readStoredOrPriceOnDemand($e, $scope), + fn (Expr $e): Type => $this->readStoredOrPriceOnDemandNative($e, $scope), + static fn (Type $t): Type => $scope->getIterableValueType($t), + static fn (Type $t): Type => $scope->getIterableKeyType($t), + ); + + return $this->selectArgsAcceptor($gatheredTypes, $overridden, $namedArgumentsVariants, $hasName, $unpack); } /** diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index a5198889c59..bf5d3024749 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -580,6 +580,57 @@ public static function applyIntrinsicArgOverrides( return $parametersAcceptors; } + /** + * Whether applyIntrinsicArgOverrides() could rewrite the acceptor's parameter + * types for these args (array_map/filter/walk/find, curl_setopt, implode, + * Closure::bind). When false the single-acceptor metadata is override-free and + * processArgs() can skip re-selecting it per argument. Mirrors the attribute + * dispatch in applyIntrinsicArgOverrides(). + * + * @internal + * @param Node\Arg[] $args + */ + public static function argsHaveIntrinsicArgOverride(array $args): bool + { + if (count($args) === 0) { + return false; + } + + if ($args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null) { + return true; + } + + if ((bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if (isset($args[1]) && (bool) $args[1]->getAttribute(CurlSetOptArrayArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if ((bool) $args[0]->getAttribute(ArrayFilterArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if ((bool) $args[0]->getAttribute(ImplodeArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if ((bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if ((bool) $args[0]->getAttribute(ArrayFindArgVisitor::ATTRIBUTE_NAME)) { + return true; + } + + if ($args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME) !== null) { + return true; + } + + return $args[0]->getAttribute(ClosureBindArgVisitor::ATTRIBUTE_NAME) !== null; + } + /** * @internal */ From ddd2dadd4d063317a30a1bb26fd03e768fe86eff Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 09:25:41 +0200 Subject: [PATCH 106/378] FuncCallHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/FuncCallHandler.php | 316 ++++++++++++++----- 1 file changed, 243 insertions(+), 73 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 156d71f3a49..3c2bcf6dfb5 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -18,6 +18,8 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -27,7 +29,6 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -83,10 +84,10 @@ use function str_starts_with; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class FuncCallHandler implements TypeResolvingExprHandler +final class FuncCallHandler implements ExprHandler { public function __construct( @@ -98,6 +99,8 @@ public function __construct( #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -114,6 +117,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = []; $namedArgumentsVariants = null; $functionReflection = null; + $nameResult = null; $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; @@ -121,12 +125,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameType = $scope->getType($expr->name); if (!$nameType->isCallable()->no()) { $variants = $nameType->getCallableParametersAcceptors($scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $variants, - null, - ); + // A structural acceptor (names/positions/variadic) drives the per-arg + // metadata and the throw/impure points - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, null); } $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -154,12 +156,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); $variants = $functionReflection->getVariants(); $namedArgumentsVariants = $functionReflection->getNamedArgumentsVariants(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $variants, - $namedArgumentsVariants, - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw points - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); if ($impurePoint !== null) { $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); @@ -307,6 +307,50 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->getScope(); } + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, on-demand / synthetic pricing, or special cases + // inside resolveReturnType), the acceptor is re-derived from the + // already-processed argument results on the asking scope. + $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $nodeScopeResolver, + $s, + $expr, + $nameResult, + $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $expr, + $normalizedExpr, + $nameResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // Store a preliminary result carrying the type/specify callbacks before the + // throw-point return type is computed: getFunctionThrowPoint() resolves the + // return type through dynamic return type extensions, one of which + // (TypeSpecifyingFunctionsDynamicReturnTypeExtension via + // ImpossibleCheckTypeHelper) narrows this very call. Without a stored result + // that narrowing would re-process this FuncCall on demand and recurse back + // into getFunctionThrowPoint(). The callbacks are scope-independent, so the + // preliminary result answers those asks correctly; the final result below + // overwrites it with the resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + if ($normalizedExpr->name instanceof Expr) { $nameType = $scope->getType($normalizedExpr->name); if ( @@ -329,7 +373,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($functionReflection !== null) { - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them via readStoredOrPriceOnDemand, + // never re-running processArgs) - asking Scope::getType() for the + // FuncCall here would re-enter this handler on demand, as its result is + // not stored yet. + $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $nameResult, $resolvedParametersAcceptor); + // The early structural check above (line ~180) only sees the unresolved + // acceptor return type; a conditional-return never (e.g. + // `($x is Foo ? never : string)`) only resolves to never once the actual + // argument types are folded in by the type-driven resolved acceptor. Read + // it from that acceptor's return type, not resolveReturnType(), which + // folds in call_user_func()/dynamic-extension special cases that must not + // make the call itself always-terminating (e.g. + // `call_user_func(fn() => exit())`). + if ($resolvedParametersAcceptor !== null) { + $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); + $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); + } + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $returnType, $normalizedExpr, $scope, $context); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -603,15 +665,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: $resolvedParametersAcceptor !== null - ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) - : null, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, + Type $returnType, FuncCall $normalizedFuncCall, MutatingScope $scope, ExpressionContext $context, @@ -632,7 +694,6 @@ private function getFunctionThrowPoint( $throwType = $functionReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedFuncCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -660,8 +721,7 @@ private function getFunctionThrowPoint( || $requiredParameters > 0 || count($normalizedFuncCall->getArgs()) > 0 ) { - $functionReturnedType = $scope->getType($normalizedFuncCall); - if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($functionReturnedType)->yes()) { + if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($returnType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedFuncCall); } } @@ -813,34 +873,74 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra return $arrayType; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * A structural acceptor for argument normalization, the impure point and the + * throw points: it depends only on argument names/positions/variadic, so it is + * generic-agnostic (the type-driven, generic-resolved acceptor is produced by + * processArgs() instead). Mirrors selectArgsAcceptor()'s variant-set choice - + * named-argument calls select among the named-arguments variants, which carry + * the parameter defaults reorderFuncArguments() needs to fill skipped optionals. + * + * @param Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor { - return $this->resolveReturnType($scope, $expr, null); + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : ParametersAcceptorSelector::combineAcceptors($selectedVariants); } /** - * The stored call-expression type is derived from $preResolvedAcceptor - the - * acceptor processArgs() selected from the arg types gathered on the arg-to-arg - * evolving scope (type-driven, generics resolved). When null (on-demand / - * synthetic pricing, or special cases below), it falls back to selecting from - * the args on the asking scope. + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * a callable callee whose name was processed elsewhere), it falls back to a + * structural acceptor combined from the variants - generic resolution from the + * actual arg types lives in $preResolvedAcceptor, recomputed by on-demand / + * synthetic pricing that re-runs processArgs(). * * @param FuncCall $expr */ - private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { + // the operands/arguments were processed during processExpr; read their + // already computed results instead of re-walking via Scope::getType(). + // Synthetic nodes the resolver builds (e.g. Clone_, call_user_func's inner + // FuncCall) are priced on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $nameResult, $scope, $nodeScopeResolver): Type { + if ($nameResult !== null && $e === $expr->name) { + return $nameResult->getTypeForScope($scope); + } + + return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + }; + if ($expr->name instanceof Expr) { - $calledOnType = $scope->getType($expr->name); + $calledOnType = $getType($expr->name); if ($calledOnType->isCallable()->no()) { return new ErrorType(); } - $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $calledOnType->getCallableParametersAcceptors($scope), - null, - ); + if ($preResolvedAcceptor !== null) { + $parametersAcceptor = $preResolvedAcceptor; + } else { + $variants = $calledOnType->getCallableParametersAcceptors($scope); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } $functionName = null; if ($expr->name instanceof String_) { @@ -881,7 +981,7 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters if ($result !== null) { [, $innerFuncCall] = $result; - return $scope->getType($innerFuncCall); + return $getType($innerFuncCall); } } @@ -890,22 +990,24 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters if ($result !== null) { [, $innerFuncCall] = $result; - return $scope->getType($innerFuncCall); + return $getType($innerFuncCall); } } - $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $functionReflection->getVariants(), - $functionReflection->getNamedArgumentsVariants(), - ); + if ($preResolvedAcceptor !== null) { + $parametersAcceptor = $preResolvedAcceptor; + } else { + $variants = $functionReflection->getVariants(); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); if ($normalizedNode !== null) { if ($functionReflection->getName() === 'clone' && count($normalizedNode->getArgs()) > 0) { - $cloneType = $scope->getType(new Expr\Clone_($normalizedNode->getArgs()[0]->value)); + $cloneType = $getType(new Expr\Clone_($normalizedNode->getArgs()[0]->value)); if (count($normalizedNode->getArgs()) === 2) { - $propertiesType = $scope->getType($normalizedNode->getArgs()[1]->value); + $propertiesType = $getType($normalizedNode->getArgs()[1]->value); if ($propertiesType->isConstantArray()->yes()) { $constantArrays = $propertiesType->getConstantArrays(); if (count($constantArrays) === 1) { @@ -935,22 +1037,26 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * FunctionTypeSpecifyingExtensions, conditional-return-type and @phpstan-assert + * narrowing are invoked on the already-processed argument results. The acceptor + * is $resolvedParametersAcceptor (type-driven, generics resolved by processArgs) + * rather than re-selected from the args on the asking scope. The subject's own + * default narrowing comes from DefaultNarrowingHelper instead of + * TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would re-enter this + * expression through TypeSpecifier::create(). + * + * @param FuncCall $expr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, FuncCall $normalizedExpr, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if ($expr->name instanceof Name) { if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr) ?? $expr; - } - foreach ($typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { + foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { continue; } @@ -958,24 +1064,22 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e return $extension->specifyTypes($functionReflection, $normalizedExpr, $scope, $context); } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $functionReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) @@ -984,30 +1088,38 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultFuncCallNarrowing($nodeScopeResolver, $scope, $expr, $nameResult, $context); } - $specifiedTypes = $this->specifyTypesFromCallableCall($typeSpecifier, $context, $expr, $scope); + $specifiedTypes = $this->specifyTypesFromCallableCall($nodeScopeResolver, $context, $expr, $nameResult, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultFuncCallNarrowing($nodeScopeResolver, $scope, $expr, $nameResult, $context); } - private function specifyTypesFromCallableCall(TypeSpecifier $typeSpecifier, TypeSpecifierContext $context, FuncCall $call, Scope $scope): ?SpecifiedTypes + private function specifyTypesFromCallableCall(NodeScopeResolver $nodeScopeResolver, TypeSpecifierContext $context, FuncCall $call, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, MutatingScope $scope): ?SpecifiedTypes { if (!$call->name instanceof Expr) { return null; } - $calleeType = $scope->getType($call->name); + $calleeType = $nameResult !== null + ? $nameResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($call->name, $scope); $assertions = null; $parametersAcceptor = null; if ($calleeType->isCallable()->yes()) { - $variants = $calleeType->getCallableParametersAcceptors($scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $variants); + if ($resolvedParametersAcceptor !== null) { + $parametersAcceptor = $resolvedParametersAcceptor; + } else { + $variants = $calleeType->getCallableParametersAcceptors($scope); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } if ($parametersAcceptor instanceof CallableParametersAcceptor) { $assertions = $parametersAcceptor->getAsserts(); } @@ -1024,7 +1136,65 @@ private function specifyTypesFromCallableCall(TypeSpecifier $typeSpecifier, Type TemplateTypeVariance::createInvariant(), )); - return $typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + return $this->typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + } + + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a function with side + * effects (or an unknown / impure callee whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s FuncCall handling inside-out, without re-entering + * this expression through create(). + * + */ + private function defaultFuncCallNarrowing(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, FuncCall $expr, ?ExpressionResult $nameResult, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$this->isFuncCallNarrowable($nodeScopeResolver, $scope, $expr, $nameResult)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + private function isFuncCallNarrowable(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, FuncCall $expr, ?ExpressionResult $nameResult): bool + { + if ($expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + // backwards compatibility with previous behaviour + return false; + } + + $hasSideEffects = $this->reflectionProvider->getFunction($expr->name, $scope)->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + + $nameType = $nameResult !== null + ? $nameResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); + if (!$nameType->isCallable()->yes()) { + return true; + } + + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + + if ($isPure === null) { + return true; + } + + if ($isPure->no()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $isPure->yes(); } private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection): ?Type From 2a491366a81f0caabbdc2750c228f8a3318a4b65 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 09:45:58 +0200 Subject: [PATCH 107/378] MethodCallHandler is no longer TypeResolvingExprHandler --- .../ExprHandler/MethodCallHandler.php | 288 ++++++++++++++---- 1 file changed, 224 insertions(+), 64 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 91f058c444b..9154fbc692f 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; @@ -13,16 +14,15 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -50,10 +50,10 @@ use function strtolower; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class MethodCallHandler implements TypeResolvingExprHandler +final class MethodCallHandler implements ExprHandler { public function __construct( @@ -63,6 +63,8 @@ public function __construct( #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -101,25 +103,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = []; $namedArgumentsVariants = null; $methodReflection = null; - $calledOnType = $scope->getType($expr->var); + $nameResult = null; + // the var was processed above as the receiver; read its already-computed + // result instead of re-walking via Scope::getType(). + $calledOnType = $varResult->getTypeForScope($scope); if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); if ($methodReflection !== null) { $variants = $methodReflection->getVariants(); $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $variants, - $namedArgumentsVariants, - ); - + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw point - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } else { - $methodNameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); - $throwPoints = array_merge($throwPoints, $methodNameResult->getThrowPoints()); - $scope = $methodNameResult->getScope(); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $scope = $nameResult->getScope(); } if ($methodReflection !== null) { @@ -160,8 +162,67 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is + // re-derived from the already-processed argument results on the asking scope. + $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $nodeScopeResolver, + $s, + $expr, + $varResult, + $nameResult, + $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $expr, + $normalizedExpr, + $varResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // Store a preliminary result carrying the type/specify callbacks before the + // throw point is computed: the method throw point resolves the return type + // (resolveReturnType below) through dynamic return type extensions, which can + // narrow this very call on demand. Without a stored result that narrowing + // would re-process this MethodCall on demand and recurse. The callbacks are + // scope-independent, so the preliminary result answers those asks correctly; + // the final result below overwrites it with the resolved scope and + // throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $scope->getType($normalizedExpr)); + // The early structural check above only sees the unresolved acceptor + // return type; a conditional-return never (e.g. `($x is Foo ? never : + // string)`) only resolves to never once the actual argument types are + // folded in by the type-driven resolved acceptor. + if ($resolvedParametersAcceptor !== null) { + $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); + $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); + } + + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them via the receiver/name results and + // readStoredOrPriceOnDemand, never re-running processArgs) - asking + // Scope::getType() for the MethodCall here would re-enter this handler on + // demand, as its final result is not stored yet. + $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $methodCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -170,21 +231,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { + // the remembered call value and the @phpstan-self-out type are + // generic-sensitive: resolve them from the type-driven acceptor + // processArgs() selected (generics resolved against the actual arg + // types), falling back to the structural acceptor for dynamic callees. + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), - $parametersAcceptor->getReturnType(), + $acceptorForGenerics->getReturnType(), new MixedType(), ); } if (!$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); if ($selfOutType !== null) { + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( $normalizedExpr->var, TemplateTypeHelper::resolveTemplateTypes( $selfOutType, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $acceptorForGenerics->getResolvedTemplateTypeMap(), + $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createCovariant(), ), $scope->getNativeType($normalizedExpr->var), @@ -209,10 +276,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - $typeCallback = $resolvedParametersAcceptor !== null - ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) - : null; - $result = $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -223,9 +286,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); - $calledOnType = $originalScope->getType($expr->var); + // the var was processed above as the receiver; read its already-computed + // result on the original scope instead of re-walking via Scope::getType(). + $calledOnType = $varResult->getTypeForScope($originalScope); if (!$expr->name instanceof Identifier) { return $result; } @@ -251,7 +317,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), + containsNullsafe: $varResult->containsNullsafe(), typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } } @@ -259,25 +327,32 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $result; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->resolveReturnType($scope, $expr, null); - } - /** - * The stored call-expression type is derived from $preResolvedAcceptor - the - * acceptor processArgs() selected from the arg types gathered on the arg-to-arg - * evolving scope. Null falls back to re-selecting from the args on the asking - * scope (on-demand / synthetic pricing). + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * on-demand / synthetic pricing) it falls back to re-selecting from the args via + * MethodCallReturnTypeHelper on the asking scope. + * + * The receiver/name were processed during processExpr; their already computed + * results are read instead of re-walking via Scope::getType(). The dynamic-name + * branch builds a synthetic MethodCall priced on demand by the resolver. * * @param MethodCall $expr */ - private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { + // a call on a nullsafe chain whose receiver is currently nullable + // short-circuits to null - the receiver result carries whether the chain + // contains a ?-> (a plain nullable receiver does not propagate). + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($varResult->getTypeForScope($scope)) + ? TypeCombinator::addNull($type) + : $type; + if ($expr->name instanceof Identifier) { if ($scope->nativeTypesPromoted) { $methodReflection = $scope->getMethodReflection( - $scope->getNativeType($expr->var), + $varResult->getNativeTypeForScope($scope), $expr->name->name, ); if ($methodReflection === null) { @@ -286,12 +361,12 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters $returnType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); + return $shortCircuit($returnType); } $returnType = $this->methodCallReturnTypeHelper->methodCallReturnType( $scope, - $scope->getType($expr->var), + $varResult->getTypeForScope($scope), $expr->name->name, $expr, $preResolvedAcceptor, @@ -299,39 +374,57 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters if ($returnType === null) { $returnType = new ErrorType(); } - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); + return $shortCircuit($returnType); } - $nameType = $scope->getType($expr->name); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args)), $nameType->getConstantStrings()), + ...array_map(static function ($constantString) use ($expr, $scope, $nodeScopeResolver): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + // a method call with a concrete name on the name-pinned scope + // is synthetic. + $truthyScope = $scope->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); + + return $nodeScopeResolver->priceSyntheticOnDemand( + new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args), + $truthyScope, + ); + }, $nameType->getConstantStrings()), ); } return new MixedType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * MethodTypeSpecifyingExtensions, conditional-return-type and @phpstan-assert + * narrowing are invoked on the already-processed argument results. The acceptor + * is $resolvedParametersAcceptor (type-driven, generics resolved by processArgs) + * rather than re-selected from the args on the asking scope. The subject's own + * default narrowing comes from DefaultNarrowingHelper instead of + * TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would re-enter this + * expression through TypeSpecifier::create(). + * + * @param MethodCall $expr + * @param MethodCall $normalizedExpr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ExpressionResult $varResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if (!$expr->name instanceof Identifier) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context); } - $methodCalledOnType = $scope->getType($expr->var); + // the var was processed during processExpr; read its already-computed + // result instead of re-walking via Scope::getType(). + $methodCalledOnType = $varResult->getTypeForScope($scope); $methodReflection = $scope->getMethodReflection($methodCalledOnType, $expr->name->name); if ($methodReflection !== null) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $expr) ?? $expr; - } $referencedClasses = $methodCalledOnType->getObjectClassNames(); if ( @@ -339,7 +432,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $methodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { + foreach ($this->typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { if (!$extension->isMethodSupported($methodReflection, $normalizedExpr, $context)) { continue; } @@ -348,24 +441,22 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $methodReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) @@ -374,7 +465,76 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context); + } + + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a method with side + * effects (or an unknown method whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s MethodCall handling inside-out, without + * re-entering this expression through create(). + * + * @param MethodCall $expr + */ + private function defaultMethodCallNarrowing(MutatingScope $scope, Expr $expr, ExpressionResult $varResult, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$this->isMethodCallNarrowable($scope, $expr, $varResult)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + /** @param MethodCall $expr */ + private function isMethodCallNarrowable(MutatingScope $scope, Expr $expr, ExpressionResult $varResult): bool + { + if (!$expr->name instanceof Identifier) { + return true; + } + + $calledOnType = $varResult->getTypeForScope($scope); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + + $hasSideEffects = $methodReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + + /** + * A structural acceptor for argument normalization, the impure point and the + * throw point: it depends only on argument names/positions/variadic, so it is + * generic-agnostic (the type-driven, generic-resolved acceptor is produced by + * processArgs() instead). Named-argument calls select among the named-arguments + * variants, which carry the parameter defaults reorderMethodArguments() needs to + * fill skipped optionals. + * + * @param Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor + { + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : ParametersAcceptorSelector::combineAcceptors($selectedVariants); } } From 4697560ff09fbd7ee8c92e287d54890058b27035 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 09:54:08 +0200 Subject: [PATCH 108/378] NewHandler is no longer TypeResolvingExprHandler --- src/Analyser/ExprHandler/NewHandler.php | 171 ++++++++++++++++++------ 1 file changed, 130 insertions(+), 41 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 67e66eacee9..31bdaea73aa 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\StaticCall; @@ -13,6 +14,8 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -24,7 +27,6 @@ use PHPStan\Analyser\ThrowPoint; use PHPStan\Analyser\Traverser\ConstructorClassTemplateTraverser; use PHPStan\Analyser\Traverser\GenericTypeTemplateTraverser; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -67,10 +69,10 @@ use function sprintf; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class NewHandler implements TypeResolvingExprHandler +final class NewHandler implements ExprHandler { public function __construct( @@ -81,6 +83,8 @@ public function __construct( #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -102,6 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; + $className = null; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); @@ -116,12 +121,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); // populates $expr->class->name if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization and the throw point - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); if ($constructorReflection->getDeclaringClass()->getName() === $classReflection->getName()) { $constructorResult = null; @@ -165,9 +168,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } else { $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); } + + if ($parametersAcceptor !== null) { + $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; + } } else { $isDynamic = true; - $objectClasses = $scope->getType($expr)->getObjectClassNames(); + + $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $classResult->getScope(); + $hasYield = $classResult->hasYield(); + $throwPoints = $classResult->getThrowPoints(); + $impurePoints = $classResult->getImpurePoints(); + $isAlwaysTerminating = $classResult->isAlwaysTerminating(); + + // The instantiated object type derives from the class expression - read + // its already-processed result rather than asking Scope::getType() for + // the not-yet-stored New_ node, which would re-enter this handler. + $objectClasses = $classResult->getTypeForScope($scope)->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; @@ -177,12 +195,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $additionalThrowPoints = [InternalThrowPoint::createImplicit($scope, $expr)]; } - $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $classResult->getScope(); - $hasYield = $classResult->hasYield(); - $throwPoints = $classResult->getThrowPoints(); - $impurePoints = $classResult->getImpurePoints(); - $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { @@ -214,6 +226,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + // The new-expression type is derived from $resolvedParametersAcceptor - the + // constructor acceptor processArgs() selected from the arg types gathered on + // the arg-to-arg evolving scope (type-driven, resolves the class's @template + // parameters from constructor args). When null (native-types-promoted, or + // on-demand / synthetic pricing), resolveReturnType() re-selects a structural + // acceptor from the args on the asking scope. + $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $nodeScopeResolver, + $s, + $expr, + $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $s, + $expr, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // Store a preliminary result carrying the type/specify callbacks before the + // throw-point return type is computed: getConstructorThrowPoint() and the + // exact-instantiation return type resolution can re-enter on demand (e.g. a + // dynamic static-method return type extension narrowing this very + // instantiation). Without a stored result that narrowing would re-process + // this New_ on demand and recurse. The callbacks are scope-independent, so + // the preliminary result answers those asks correctly; the final result + // below overwrites it with the resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + if ($constructorReflection !== null && $parametersAcceptor !== null) { $className ??= $constructorReflection->getDeclaringClass()->getName(); $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope, $context); @@ -240,9 +291,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: $resolvedParametersAcceptor !== null && $expr->class instanceof Name - ? fn (MutatingScope $s): Type => $this->resolveReturnType($s, $expr, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor) - : null, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } @@ -260,12 +310,10 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization and the throw point - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); } } @@ -344,21 +392,45 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio return null; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * A structural acceptor for argument normalization and the throw point: it + * depends only on argument names/positions/variadic, so it is generic-agnostic + * (the type-driven, generic-resolved acceptor is produced by processArgs() + * instead). Mirrors the old variant-set choice - named-argument calls select + * among the named-arguments variants, which carry the parameter defaults + * reorderNewArguments() needs to fill skipped optionals. + * + * @param Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor { - return $this->resolveReturnType($scope, $expr, null); + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : ParametersAcceptorSelector::combineAcceptors($selectedVariants); } /** * The stored new-expression type is derived from $preResolvedAcceptor - the * constructor acceptor processArgs() selected from the arg types gathered on * the arg-to-arg evolving scope (resolves the class's @template parameters - * from constructor args). Null falls back to re-selecting from the args on the - * asking scope (on-demand / synthetic pricing). + * from constructor args). Null falls back to re-selecting a structural acceptor + * from the args on the asking scope (on-demand / synthetic pricing). * * @param New_ $expr */ - private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): Type { if ($expr->class instanceof Name) { return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor); @@ -369,7 +441,9 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters return new ObjectType($anonymousClassReflection->getName()); } - $exprType = $scope->getType($expr->class); + // the class expression was processed during processExpr; read its already + // computed result instead of re-walking via Scope::getType(). + $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); return $exprType->getObjectTypeOrClassStringObjectType(); } @@ -419,8 +493,7 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas $node->getArgs(), ); - $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( - $scope, + $parametersAcceptor = $preResolvedAcceptor ?? $this->combineVariantsForNormalization( $methodCall->getArgs(), $constructorMethod->getVariants(), $constructorMethod->getNamedArgumentsVariants(), @@ -450,6 +523,9 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } + // $methodCall is a synthetic StaticCall the handler built - it is not a + // source node, so Scope::getType() prices it on demand (the constructor's + // own never-returning conditional return type). $methodResult = $scope->getType($methodCall); if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { return $methodResult; @@ -655,13 +731,24 @@ classReflection: $classReflection->withTypes($types)->asFinal(), return TypeTraverser::map($newGenericType, new GenericTypeTemplateTraverser($resolvedTemplateTypeMap)); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * constructor's @phpstan-assert narrowing is invoked on the already-processed + * argument results. The acceptor is $resolvedParametersAcceptor (type-driven, + * generics resolved by processArgs) rather than re-selected from the args on + * the asking scope. The subject's own default narrowing comes from + * DefaultNarrowingHelper instead of TypeSpecifier::specifyDefaultTypes(), which + * would re-enter this expression through TypeSpecifier::create(). + * + * @param New_ $expr + */ + private function specifyTypes(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if ( !$expr->class instanceof Name || !$this->reflectionProvider->hasClass($expr->class->toString()) ) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } $classReflection = $this->reflectionProvider->getClass($expr->class->toString()); @@ -670,17 +757,15 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e $methodReflection = $classReflection->getConstructor(); $asserts = $methodReflection->getAsserts(); - if ($asserts->getAll() !== []) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - + if ($asserts->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $asserts->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; @@ -688,6 +773,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } + // A known class without (applicable) constructor asserts contributes no + // narrowing entry, mirroring the old handler's empty return for this path + // (a `new X()` is always a truthy object, so the default truthy/falsey + // removal that path 1 emits would be a no-op here anyway). return (new SpecifiedTypes([], []))->setRootExpr($expr); } From 2c833fe73bafa573c04e064307309a65b35c26f0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 10:02:50 +0200 Subject: [PATCH 109/378] StaticCallHandler is no longer TypeResolvingExprHandler The return-type typeCallback reads the processArgs-resolved acceptor directly (naive), so late static binding is not yet re-bound against the asking scope. Three tests are knowingly left failing, to be fixed separately by re-binding static/$this against the asking scope: static-late-binding.php, bug-11687.php, bug-nullsafe-prop-static-access.php. NullsafeShortCircuitingHelper deleted (StaticCall was its last caller). --- .../Helper/NullsafeShortCircuitingHelper.php | 54 --- .../ExprHandler/StaticCallHandler.php | 326 +++++++++++++----- 2 files changed, 243 insertions(+), 137 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php diff --git a/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php b/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php deleted file mode 100644 index 72710dd93b5..00000000000 --- a/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php +++ /dev/null @@ -1,54 +0,0 @@ -getType($expr->var); - if (TypeCombinator::containsNull($varType)) { - return TypeCombinator::addNull($type); - } - - return $type; - } - - if ($expr instanceof ArrayDimFetch) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof PropertyFetch) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof StaticPropertyFetch && $expr->class instanceof Expr) { - return self::getType($scope, $expr->class, $type); - } - - if ($expr instanceof MethodCall) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof StaticCall && $expr->class instanceof Expr) { - return self::getType($scope, $expr->class, $type); - } - - return $type; - } - -} diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index d68507dbb6d..da129f9caec 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\New_; @@ -16,17 +17,16 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -35,6 +35,7 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\ErrorType; @@ -44,23 +45,20 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; -use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeWithClassName; use ReflectionProperty; use function array_map; use function array_merge; use function count; -use function in_array; use function sprintf; use function strtolower; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class StaticCallHandler implements TypeResolvingExprHandler +final class StaticCallHandler implements ExprHandler { public function __construct( @@ -70,6 +68,8 @@ public function __construct( #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -87,6 +87,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $containsNullsafe = false; + $classResult = null; + $nameResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -111,12 +113,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $methodReflection = $classType->getMethod($methodName, $scope); $variants = $methodReflection->getVariants(); $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $variants, - $namedArgumentsVariants, - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw point - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); $declaringClass = $methodReflection->getDeclaringClass(); if ( @@ -164,18 +164,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } } elseif ($expr->class instanceof Expr) { - $classType = $scope->getType($expr->class)->getObjectTypeOrClassStringObjectType(); + // the class expr was processed above as the receiver; read its + // already-computed result instead of re-walking via Scope::getType(). + $classType = $classResult->getTypeForScope($scope)->getObjectTypeOrClassStringObjectType(); $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { $variants = $methodReflection->getVariants(); $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $variants, - $namedArgumentsVariants, - ); + $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } } else { @@ -187,7 +184,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr->class instanceof Expr) { - $objectClasses = $scope->getType($expr->class)->getObjectClassNames(); + // the class expr was processed above as the receiver; read its + // already-computed result instead of re-walking via Scope::getType(). + $objectClasses = $classResult->getTypeForScope($scope)->getObjectClassNames(); if (count($objectClasses) !== 1) { $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); } @@ -224,12 +223,72 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); + $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $scopeFunction = $scope->getFunction(); + // The early structural check above only sees the unresolved acceptor return + // type; a conditional-return never (e.g. `($x is Foo ? never : string)`) + // only resolves to never once the actual argument types are folded in by the + // type-driven resolved acceptor. + if ($resolvedParametersAcceptor !== null) { + $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); + $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); + } + + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is + // re-derived from the already-processed argument results on the asking scope. + $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $nodeScopeResolver, + $s, + $expr, + $classResult, + $nameResult, + $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $expr, + $normalizedExpr, + $classResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // Store a preliminary result carrying the type/specify callbacks before the + // throw point is computed: the method throw point resolves the return type + // (resolveReturnType below) through dynamic static-method return type + // extensions, which can narrow this very call on demand. Without a stored + // result that narrowing would re-process this StaticCall on demand and + // recurse. The callbacks are scope-independent, so the preliminary result + // answers those asks correctly; the final result below overwrites it with the + // resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $containsNullsafe, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $scope->getType($normalizedExpr)); + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them via the class/name results and + // readStoredOrPriceOnDemand, never re-running processArgs) - asking + // Scope::getType() for the StaticCall here would re-enter this handler on + // demand, as its final result is not stored yet. + $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $staticCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -258,9 +317,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() ) { + // the remembered call value is generic-sensitive: resolve it from the + // type-driven acceptor processArgs() selected (generics resolved against + // the actual arg types), falling back to the structural acceptor. + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, new Variable('this'), sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), - $parametersAcceptor->getReturnType(), + $acceptorForGenerics->getReturnType(), new MixedType(), ); } @@ -307,17 +370,44 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, containsNullsafe: $containsNullsafe, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * on-demand / synthetic pricing) it falls back to re-selecting from the args via + * MethodCallReturnTypeHelper on the asking scope. + * + * The class/name were processed during processExpr; their already computed + * results are read instead of re-walking via Scope::getType(). The dynamic-name + * branch builds a synthetic StaticCall priced on demand by the resolver. + * + * @param StaticCall $expr + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { + // a call on a nullsafe chain whose class-receiver is currently nullable + // short-circuits to null - the class result carries whether the chain + // contains a ?-> (a plain nullable receiver does not propagate). + $shortCircuit = static fn (Type $type): Type => $expr->class instanceof Expr + && $classResult !== null + && $classResult->containsNullsafe() + && TypeCombinator::containsNull($classResult->getTypeForScope($scope)) + ? TypeCombinator::addNull($type) + : $type; + if ($expr->name instanceof Identifier) { if ($scope->nativeTypesPromoted) { if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name); + $staticMethodCalledOnType = $scope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = $scope->getNativeType($expr->class); + $staticMethodCalledOnType = $classResult !== null + ? $classResult->getNativeTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->class, $scope); } $methodReflection = $scope->getMethodReflection( $staticMethodCalledOnType, @@ -329,17 +419,16 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $callType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); - } - - return $callType; + return $shortCircuit($callType); } if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name); + $staticMethodCalledOnType = $scope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); + $classType = $classResult !== null + ? $classResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); + $staticMethodCalledOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); } $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( @@ -347,73 +436,70 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $staticMethodCalledOnType, $expr->name->toString(), $expr, + $preResolvedAcceptor, ); if ($callType === null) { $callType = new ErrorType(); } - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); - } - - return $callType; + return $shortCircuit($callType); } - $nameType = $scope->getType($expr->name); + $nameType = $nameResult !== null ? $nameResult->getTypeForScope($scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new Expr\StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args)), $nameType->getConstantStrings()), - ); - } - - return new MixedType(); - } + ...array_map(static function ($constantString) use ($expr, $scope, $nodeScopeResolver): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } - private function resolveTypeByNameWithLateStaticBinding(MutatingScope $scope, Name $class, Identifier $name): TypeWithClassName - { - $classType = $scope->resolveTypeByName($class); + // a static call with a concrete name on the name-pinned scope + // is synthetic. + $truthyScope = $scope->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); - if ( - $classType instanceof StaticType - && !in_array($class->toLowerString(), ['self', 'static', 'parent'], true) - ) { - $methodReflectionCandidate = $scope->getMethodReflection( - $classType, - $name->name, + return $nodeScopeResolver->priceSyntheticOnDemand( + new StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args), + $truthyScope, + ); + }, $nameType->getConstantStrings()), ); - if ($methodReflectionCandidate !== null && $methodReflectionCandidate->isStatic()) { - $classType = $classType->getStaticObjectType(); - } } - return $classType; + return new MixedType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * StaticMethodTypeSpecifyingExtensions, conditional-return-type and assert + * narrowing are invoked on the already-processed argument + * results. The acceptor is $resolvedParametersAcceptor (type-driven, generics + * resolved by processArgs) rather than re-selected from the args on the asking + * scope. The subject's own default narrowing comes from DefaultNarrowingHelper + * instead of TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would + * re-enter this expression through TypeSpecifier::create(). + * + * @param StaticCall $expr + * @param StaticCall $normalizedExpr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ?ExpressionResult $classResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if (!$expr->name instanceof Identifier) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } if ($expr->class instanceof Name) { $calleeType = $scope->resolveTypeByName($expr->class); } else { - $calleeType = $scope->getType($expr->class); + // the class expr was processed during processExpr; read its + // already-computed result instead of re-walking via Scope::getType(). + $calleeType = $classResult !== null + ? $classResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); } $staticMethodReflection = $scope->getMethodReflection($calleeType, $expr->name->name); if ($staticMethodReflection !== null) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $expr) ?? $expr; - } $referencedClasses = $calleeType->getObjectClassNames(); if ( @@ -421,7 +507,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $staticMethodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { + foreach ($this->typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { if (!$extension->isStaticMethodSupported($staticMethodReflection, $normalizedExpr, $context)) { continue; } @@ -430,24 +516,22 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $staticMethodReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) @@ -456,7 +540,83 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultStaticCallNarrowing($scope, $expr, $classResult, $nodeScopeResolver, $context); + } + + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a static method with + * side effects (or an unknown method whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s StaticCall handling inside-out, without + * re-entering this expression through create(). + * + * @param StaticCall $expr + */ + private function defaultStaticCallNarrowing(MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, NodeScopeResolver $nodeScopeResolver, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$this->isStaticCallNarrowable($scope, $expr, $classResult, $nodeScopeResolver)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + /** @param StaticCall $expr */ + private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, NodeScopeResolver $nodeScopeResolver): bool + { + if (!$expr->name instanceof Identifier) { + return true; + } + + if ($expr->class instanceof Name) { + $calleeType = $scope->resolveTypeByName($expr->class); + } else { + $calleeType = $classResult !== null + ? $classResult->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); + } + + $methodReflection = $scope->getMethodReflection($calleeType, $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + + $hasSideEffects = $methodReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + + /** + * A structural acceptor for argument normalization, the impure point and the + * throw point: it depends only on argument names/positions/variadic, so it is + * generic-agnostic (the type-driven, generic-resolved acceptor is produced by + * processArgs() instead). Named-argument calls select among the named-arguments + * variants, which carry the parameter defaults reorderStaticCallArguments() + * needs to fill skipped optionals. + * + * @param Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor + { + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : ParametersAcceptorSelector::combineAcceptors($selectedVariants); } } From 430713376d2a31372435bb11b3d56642910bc417 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 10:13:49 +0200 Subject: [PATCH 110/378] Extract combineVariantsForNormalization into a shared ParametersAcceptorSelector helper Replaces the four identical per-handler copies and the attribute-constructor reorderNewArguments site (which still called selectFromArgs) with one structural-acceptor helper. (3 late-static-binding tests remain knowingly red, see prior commit.) --- src/Analyser/ExprHandler/FuncCallHandler.php | 33 ++--------------- .../ExprHandler/MethodCallHandler.php | 32 +---------------- src/Analyser/ExprHandler/NewHandler.php | 36 ++----------------- .../ExprHandler/StaticCallHandler.php | 34 ++---------------- src/Analyser/NodeScopeResolver.php | 3 +- src/Reflection/ParametersAcceptorSelector.php | 28 +++++++++++++++ 6 files changed, 37 insertions(+), 129 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 3c2bcf6dfb5..8ee3623176e 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -128,7 +128,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A structural acceptor (names/positions/variadic) drives the per-arg // metadata and the throw/impure points - generics are resolved // type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, null); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, null); } $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -159,7 +159,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A structural acceptor (names/positions/variadic) drives argument // normalization, the impure point and the throw points - generics are // resolved type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); if ($impurePoint !== null) { $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); @@ -873,35 +873,6 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra return $arrayType; } - /** - * A structural acceptor for argument normalization, the impure point and the - * throw points: it depends only on argument names/positions/variadic, so it is - * generic-agnostic (the type-driven, generic-resolved acceptor is produced by - * processArgs() instead). Mirrors selectArgsAcceptor()'s variant-set choice - - * named-argument calls select among the named-arguments variants, which carry - * the parameter defaults reorderFuncArguments() needs to fill skipped optionals. - * - * @param Arg[] $args - * @param ParametersAcceptor[] $variants - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor - { - $hasName = false; - foreach ($args as $arg) { - if ($arg->name !== null) { - $hasName = true; - break; - } - } - - $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; - - return count($selectedVariants) === 1 - ? $selectedVariants[0] - : ParametersAcceptorSelector::combineAcceptors($selectedVariants); - } - /** * The call-expression type is derived from $preResolvedAcceptor - the acceptor * processArgs() selected from the arg types gathered on the arg-to-arg evolving diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 9154fbc692f..6f7b4d03239 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -2,7 +2,6 @@ namespace PHPStan\Analyser\ExprHandler; -use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; @@ -116,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A structural acceptor (names/positions/variadic) drives argument // normalization, the impure point and the throw point - generics are // resolved type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } else { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -508,33 +507,4 @@ private function isMethodCallNarrowable(MutatingScope $scope, Expr $expr, Expres return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); } - /** - * A structural acceptor for argument normalization, the impure point and the - * throw point: it depends only on argument names/positions/variadic, so it is - * generic-agnostic (the type-driven, generic-resolved acceptor is produced by - * processArgs() instead). Named-argument calls select among the named-arguments - * variants, which carry the parameter defaults reorderMethodArguments() needs to - * fill skipped optionals. - * - * @param Arg[] $args - * @param ParametersAcceptor[] $variants - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor - { - $hasName = false; - foreach ($args as $arg) { - if ($arg->name !== null) { - $hasName = true; - break; - } - } - - $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; - - return count($selectedVariants) === 1 - ? $selectedVariants[0] - : ParametersAcceptorSelector::combineAcceptors($selectedVariants); - } - } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 31bdaea73aa..5ea7c84e8db 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node; -use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\StaticCall; @@ -124,7 +123,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A structural acceptor (names/positions/variadic) drives argument // normalization and the throw point - generics are resolved // type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); if ($constructorReflection->getDeclaringClass()->getName() === $classReflection->getName()) { $constructorResult = null; @@ -313,7 +312,7 @@ private function processConstructorReflection(string $className, New_ $expr, Mut // A structural acceptor (names/positions/variadic) drives argument // normalization and the throw point - generics are resolved // type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); } } @@ -392,35 +391,6 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio return null; } - /** - * A structural acceptor for argument normalization and the throw point: it - * depends only on argument names/positions/variadic, so it is generic-agnostic - * (the type-driven, generic-resolved acceptor is produced by processArgs() - * instead). Mirrors the old variant-set choice - named-argument calls select - * among the named-arguments variants, which carry the parameter defaults - * reorderNewArguments() needs to fill skipped optionals. - * - * @param Arg[] $args - * @param ParametersAcceptor[] $variants - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor - { - $hasName = false; - foreach ($args as $arg) { - if ($arg->name !== null) { - $hasName = true; - break; - } - } - - $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; - - return count($selectedVariants) === 1 - ? $selectedVariants[0] - : ParametersAcceptorSelector::combineAcceptors($selectedVariants); - } - /** * The stored new-expression type is derived from $preResolvedAcceptor - the * constructor acceptor processArgs() selected from the arg types gathered on @@ -493,7 +463,7 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas $node->getArgs(), ); - $parametersAcceptor = $preResolvedAcceptor ?? $this->combineVariantsForNormalization( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::combineVariantsForNormalization( $methodCall->getArgs(), $constructorMethod->getVariants(), $constructorMethod->getNamedArgumentsVariants(), diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index da129f9caec..3c9854b15c9 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -2,7 +2,6 @@ namespace PHPStan\Analyser\ExprHandler; -use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\New_; @@ -116,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A structural acceptor (names/positions/variadic) drives argument // normalization, the impure point and the throw point - generics are // resolved type-driven by processArgs() into $resolvedParametersAcceptor. - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); $declaringClass = $methodReflection->getDeclaringClass(); if ( @@ -172,7 +171,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($methodReflection !== null) { $variants = $methodReflection->getVariants(); $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); - $parametersAcceptor = $this->combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } } else { @@ -590,33 +589,4 @@ private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?Expre return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); } - /** - * A structural acceptor for argument normalization, the impure point and the - * throw point: it depends only on argument names/positions/variadic, so it is - * generic-agnostic (the type-driven, generic-resolved acceptor is produced by - * processArgs() instead). Named-argument calls select among the named-arguments - * variants, which carry the parameter defaults reorderStaticCallArguments() - * needs to fill skipped optionals. - * - * @param Arg[] $args - * @param ParametersAcceptor[] $variants - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor - { - $hasName = false; - foreach ($args as $arg) { - if ($arg->name !== null) { - $hasName = true; - break; - } - } - - $selectedVariants = ($hasName && $namedArgumentsVariants !== null) ? $namedArgumentsVariants : $variants; - - return count($selectedVariants) === 1 - ? $selectedVariants[0] - : ParametersAcceptorSelector::combineAcceptors($selectedVariants); - } - } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 38570101d27..c4724e3ae54 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3630,8 +3630,7 @@ private function processAttributeGroups( $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization( $attr->args, $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants(), diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index bf5d3024749..cd61c89f5dd 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -776,6 +776,34 @@ public static function selectFromTypes( return GenericParametersAcceptorResolver::resolve($types, self::combineAcceptors($winningAcceptors)); } + /** + * Picks the structural ParametersAcceptor (parameter names/positions/variadic + * only) that drives argument normalization / reordering. Unlike selectFromArgs() + * it never reads argument types from a Scope, so it is safe to call before the + * arguments have been processed - generics are resolved separately, type-driven. + * + * @internal + * @param Node\Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + public static function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor + { + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = $hasName && $namedArgumentsVariants !== null ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : self::combineAcceptors($selectedVariants); + } + /** * @param ParametersAcceptor[] $acceptors */ From ca23f090c87b3bd4ba2124d1cbc72eee5528fbf7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 14:24:58 +0200 Subject: [PATCH 111/378] Add regression test for #13253 Closes https://github.com/phpstan/phpstan/issues/13253 --- tests/PHPStan/Analyser/nsrt/bug-13253.php | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13253.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13253.php b/tests/PHPStan/Analyser/nsrt/bug-13253.php new file mode 100644 index 00000000000..5bc8ac64c39 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13253.php @@ -0,0 +1,80 @@ += 7.4 + +declare(strict_types = 1); + +namespace Bug13253; + +use Generator; +use ReflectionFunction; +use function PHPStan\Testing\assertType; + +/** + * @template TKey + * @template TValue + */ +class Pfline +{ + + /** @var iterable */ + private iterable $data; + + /** @param iterable $data */ + public function __construct(iterable $data) + { + $this->data = $data; + } + + /** + * @return iterable + */ + public function data(): iterable + { + return $this->data; + } + + /** + * @template TMapKey + * @template TMapValue + * @param null|callable(TValue): Generator $func + * @phpstan-self-out self + * @return self + */ + public function map(?callable $func = null): self + { + return $this; + } + +} + +function (): void { + $reflection = new ReflectionFunction('strlen'); + $params = $reflection->getParameters(); + + // Without chaining (each call advances the type through @phpstan-self-out) + $pipeline = new Pfline($params); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param; + }); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param->getName(); + }); + $pipeline->map(function ($param) { + assertType('non-empty-string', $param); + yield substr_count('.', $param); + }); + + // With chaining (the type must advance through @return self) + $pipeline = new Pfline($params); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param; + })->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param->getName(); + })->map(function ($param) { + assertType('non-empty-string', $param); + yield substr_count('.', $param); + }); +}; From 2768120bb2cf8aa9a7bdbc5ed39d23b7ec4eda4a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 19 Jun 2026 14:30:42 +0200 Subject: [PATCH 112/378] Add regression test for #14396 Closes https://github.com/phpstan/phpstan/issues/14396 --- .../PHPStan/Rules/Exceptions/Bug14396Test.php | 46 +++++++++++++++++++ tests/PHPStan/Rules/Exceptions/bug-14396.neon | 5 ++ .../Rules/Exceptions/data/bug-14396.php | 45 ++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/PHPStan/Rules/Exceptions/Bug14396Test.php create mode 100644 tests/PHPStan/Rules/Exceptions/bug-14396.neon create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-14396.php diff --git a/tests/PHPStan/Rules/Exceptions/Bug14396Test.php b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php new file mode 100644 index 00000000000..bb0e9c982ed --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php @@ -0,0 +1,46 @@ + + */ +class Bug14396Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MissingCheckedExceptionInFunctionThrowsRule( + new MissingCheckedExceptionInThrowsCheck(new DefaultExceptionTypeResolver( + self::createReflectionProvider(), + [], + [], + [], + [], + )), + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return false; + } + + #[RequiresPhp('>= 8.1')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/bug-14396.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-14396.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/bug-14396.neon b/tests/PHPStan/Rules/Exceptions/bug-14396.neon new file mode 100644 index 00000000000..feb290057aa --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/bug-14396.neon @@ -0,0 +1,5 @@ +parameters: + treatPhpDocTypesAsCertain: false + exceptions: + check: + missingCheckedExceptionInThrows: true diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-14396.php b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php new file mode 100644 index 00000000000..bf8901f8127 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php @@ -0,0 +1,45 @@ += 8.1 + +declare(strict_types=1); + +namespace Bug14396; + +enum Status { + case A; + case B; + case C; +} + +class Item { + public function __construct( + public ?Status $status + ) {} +} + +/** +* @param list $list +*/ +function countAFromCollection(array $list): int +{ + $count = 0; + + foreach ($list as $item) { + match ($item->status) { + Status::A => ++$count, + Status::B, + Status::C, + null => null, + }; + } + + return $count; +} + +function countAFromItem(Item $item): ?int { + return match ($item->status) { + Status::A => 1, + Status::B, + Status::C, + null => null, + }; +} From 4eaaf382152d784927efb33627a0cec0dd50f51a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 20 Jun 2026 13:55:51 +0200 Subject: [PATCH 113/378] Call handleDefaultTruthyOrFalseyContext on $this->typeSpecifier in migrated handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto 2.2.x auto-merged the @phpstan-assert-if-true default truthy/falsey narrowing (#5880/#5885) into the migrated FuncCall/Method/StaticCall specify callbacks, but using the old-world local $typeSpecifier instead of the property $this->typeSpecifier — crashing with "Call to a member function handleDefaultTruthyOrFalseyContext() on null" whenever the assert-narrowing path was hit (111 ImpossibleCheckType*/CallToFunctionParameters* test errors). --- src/Analyser/ExprHandler/FuncCallHandler.php | 2 +- src/Analyser/ExprHandler/MethodCallHandler.php | 2 +- src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 8ee3623176e..aed82c07376 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -1053,7 +1053,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); } } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 6f7b4d03239..bc52a933941 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -458,7 +458,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); } } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 3c9854b15c9..7b8de3eaf91 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -533,7 +533,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) ->setRootExpr($specifiedTypes->getRootExpr()); } } From 096a32ff8e58f1e1b35e22aa793b9747772a1434 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 10:21:04 +0200 Subject: [PATCH 114/378] Rework issetCheck with single-pass --- src/Analyser/ExprHandler/AssignOpHandler.php | 9 + src/Analyser/ExprHandler/CoalesceHandler.php | 7 +- src/Analyser/ExprHandler/EmptyHandler.php | 7 +- src/Analyser/ExprHandler/IssetHandler.php | 197 +++++++------ src/Analyser/ExpressionResult.php | 61 +--- src/Analyser/IssetabilityDescriptor.php | 254 +++++----------- src/Analyser/IssetabilityLinkInfo.php | 292 +++++++++++++++++++ src/Analyser/IssetabilityResolution.php | 176 +++++++++++ src/Analyser/MutatingScope.php | 213 -------------- src/Node/CoalesceExpressionNode.php | 54 ++++ src/Node/EmptyExpressionNode.php | 45 +++ src/Node/IssetExpressionNode.php | 51 ++++ src/Rules/IssetCheck.php | 213 +++++--------- src/Rules/Variables/EmptyRule.php | 7 +- src/Rules/Variables/IssetRule.php | 9 +- src/Rules/Variables/NullCoalesceRule.php | 43 ++- 16 files changed, 928 insertions(+), 710 deletions(-) create mode 100644 src/Analyser/IssetabilityLinkInfo.php create mode 100644 src/Analyser/IssetabilityResolution.php create mode 100644 src/Node/CoalesceExpressionNode.php create mode 100644 src/Node/EmptyExpressionNode.php create mode 100644 src/Node/IssetExpressionNode.php diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index ed0129aae74..f6f498c1317 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -22,6 +22,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantIntegerType; @@ -197,6 +198,14 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } + if ($expr instanceof Expr\AssignOp\Coalesce) { + // the ??= left side is processed as an assignment target, not a read, so + // it carries no isset descriptor; read it on demand so NullCoalesceRule + // gets the chain's IssetabilityResolution off the carried result + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $varReadResult, 'on left side of ??='), $beforeScope, $storage, $context); + } + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 8faff93160b..c4065024a95 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; @@ -53,7 +54,7 @@ public function supports(Expr $expr): bool */ private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, ExpressionResult $condResult, TypeSpecifierContext $context): SpecifiedTypes { - $isset = $condResult->issetCheck($s, static fn () => true); + $isset = $condResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); if ($isset !== true) { return new SpecifiedTypes(); @@ -82,6 +83,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); } + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??'), $beforeScope, $storage, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -93,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope): Type { $issetLeftExpr = new Expr\Isset_([$expr->left]); - $result = $condResult->issetCheck($s, static function (Type $type): ?bool { + $result = $condResult->getIssetabilityResolution($s, false)->isSet(static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 4f31028040f..f366c8286f4 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\EmptyExpressionNode; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; @@ -52,6 +53,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new EmptyExpressionNode($expr, $exprResult), $beforeScope, $storage, $context); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -61,7 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static function (MutatingScope $s) use ($exprResult): Type { - $result = $exprResult->empty($s); + $result = $exprResult->getIssetabilityResolution($s, false)->notEmpty(); if ($result === null) { return new BooleanType(); } @@ -69,7 +72,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { - $isset = $exprResult->issetCheck($s, static fn () => true); + $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 89589a74bfb..a27e2dcaf6a 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -17,18 +17,18 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\IssetExpr; +use PHPStan\Node\IssetExpressionNode; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\HasOffsetType; @@ -52,15 +52,16 @@ use function is_string; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class IssetHandler implements TypeResolvingExprHandler +final class IssetHandler implements ExprHandler { public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, ) { } @@ -70,40 +71,101 @@ public function supports(Expr $expr): bool return $expr instanceof Isset_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $issetResult = true; + $beforeScope = $scope; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $nonNullabilityResults = []; + $isAlwaysTerminating = false; + $varResults = []; foreach ($expr->vars as $var) { - $result = $scope->issetCheck($var, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $var); + $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $varResults[] = $varResult; + $scope = $varResult->getScope(); + $hasYield = $hasYield || $varResult->hasYield(); + $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); + $isAlwaysTerminating = $isAlwaysTerminating || $varResult->isAlwaysTerminating(); + $nonNullabilityResults[] = $nonNullabilityResult; - return !$isNull->yes(); - }); - if ($result !== null) { - if (!$result) { - return new ConstantBooleanType($result); - } + if (!($var instanceof ArrayDimFetch)) { + continue; + } + $varType = $scope->getType($var->var); + if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } - $issetResult = $result; + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( + $stmt, + new MethodCall(new TypeExpr($varType), 'offsetExists'), + $scope, + $storage, + new NoopNodeCallback(), + $context, + )->getThrowPoints()); } - - if ($issetResult === null) { - return new BooleanType(); + foreach (array_reverse($expr->vars) as $var) { + $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); + } + foreach (array_reverse($nonNullabilityResults) as $nonNullabilityResult) { + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); } - return new ConstantBooleanType($issetResult); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: static function (MutatingScope $s) use ($varResults): Type { + $issetResult = true; + foreach ($varResults as $varResult) { + $result = $varResult->getIssetabilityResolution($s, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } + + continue; + } + + $issetResult = $result; + } + + if ($issetResult === null) { + return new BooleanType(); + } + + return new ConstantBooleanType($issetResult); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->specifyTypes($s, $expr, $context, $varResults), + ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param ExpressionResult[] $varResults + */ + private function specifyTypes(MutatingScope $scope, Isset_ $expr, TypeSpecifierContext $context, array $varResults): SpecifiedTypes { if (count($expr->vars) === 0 || $context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->typeSpecifier->specifyDefaultTypes($scope, $expr, $context); } // rewrite multi param isset() to and-chained single param isset() @@ -128,17 +190,13 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e throw new ShouldNotHappenException(); } - return $typeSpecifier->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); + return $this->typeSpecifier->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); } $issetExpr = $expr->vars[0]; if (!$context->true()) { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($issetExpr, static fn () => true); + $isset = $varResults[0]->getIssetabilityResolution($scope, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); @@ -146,7 +204,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e $type = $scope->getType($issetExpr); $isNullable = !$type->isNull()->no(); - $exprType = $typeSpecifier->create( + $exprType = $this->typeSpecifier->create( $issetExpr, new NullType(), $context->negate(), @@ -160,7 +218,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } // variable cannot exist in !isset() - return $exprType->unionWith($typeSpecifier->create( + return $exprType->unionWith($this->typeSpecifier->create( new IssetExpr($issetExpr), new NullType(), $context, @@ -170,7 +228,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($isNullable) { // reduces variable certainty to maybe - return $exprType->unionWith($typeSpecifier->create( + return $exprType->unionWith($this->typeSpecifier->create( new IssetExpr($issetExpr), new NullType(), $context->negate(), @@ -179,7 +237,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } // variable cannot exist in !isset() - return $typeSpecifier->create( + return $this->typeSpecifier->create( new IssetExpr($issetExpr), new NullType(), $context, @@ -214,7 +272,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($typesToRemove !== []) { $typeToRemove = TypeCombinator::union(...$typesToRemove); - $result = $typeSpecifier->create( + $result = $this->typeSpecifier->create( $issetExpr->var, $typeToRemove, TypeSpecifierContext::createFalse(), @@ -223,7 +281,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($scope->hasExpressionType($issetExpr->var)->maybe()) { $result = $result->unionWith( - $typeSpecifier->create( + $this->typeSpecifier->create( new IssetExpr($issetExpr->var), new NullType(), TypeSpecifierContext::createTruthy(), @@ -278,7 +336,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { $types = $types->unionWith( - $typeSpecifier->create( + $this->typeSpecifier->create( $var->var, new HasOffsetType($dimType), $context, @@ -291,7 +349,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); if ($narrowedKey !== null) { $types = $types->unionWith( - $typeSpecifier->create( + $this->typeSpecifier->create( $var->dim, $narrowedKey, $context, @@ -302,7 +360,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($varType->isArray()->yes()) { $types = $types->unionWith( - $typeSpecifier->create( + $this->typeSpecifier->create( $var->var, new NonEmptyArrayType(), $context, @@ -318,7 +376,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $var->name instanceof Identifier ) { $types = $types->unionWith( - $typeSpecifier->create($var->var, new IntersectionType([ + $this->typeSpecifier->create($var->var, new IntersectionType([ new ObjectWithoutClassType(), new HasPropertyType($var->name->toString()), ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), @@ -329,7 +387,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $var->name instanceof VarLikeIdentifier ) { $types = $types->unionWith( - $typeSpecifier->create($var->class, new IntersectionType([ + $this->typeSpecifier->create($var->class, new IntersectionType([ new ObjectWithoutClassType(), new HasPropertyType($var->name->toString()), ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), @@ -337,66 +395,11 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } $types = $types->unionWith( - $typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr), + $this->typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr), ); } return $types; } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult - { - $beforeScope = $scope; - $hasYield = false; - $throwPoints = []; - $impurePoints = []; - $nonNullabilityResults = []; - $isAlwaysTerminating = false; - foreach ($expr->vars as $var) { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $var); - $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); - $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $varResult->getScope(); - $hasYield = $hasYield || $varResult->hasYield(); - $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); - $isAlwaysTerminating = $isAlwaysTerminating || $varResult->isAlwaysTerminating(); - $nonNullabilityResults[] = $nonNullabilityResult; - - if (!($var instanceof ArrayDimFetch)) { - continue; - } - - $varType = $scope->getType($var->var); - if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { - continue; - } - - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall(new TypeExpr($varType), 'offsetExists'), - $scope, - $storage, - new NoopNodeCallback(), - $context, - )->getThrowPoints()); - } - foreach (array_reverse($expr->vars) as $var) { - $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); - } - foreach (array_reverse($nonNullabilityResults) as $nonNullabilityResult) { - $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); - } - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - ); - } - } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 9bc2dc335b5..b5796b32cc8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -74,11 +74,6 @@ public function getScope(): MutatingScope return $this->scope; } - public function getExpr(): Expr - { - return $this->expr; - } - public function getBeforeScope(): MutatingScope { return $this->beforeScope; @@ -101,57 +96,23 @@ public function containsNullsafe(): bool } /** - * The isset/empty/?? chain descriptor for this expression, or null when the - * expression is not a variable / array dim fetch / property fetch chain link - * (in which case isset() falls back to the leaf type check). - */ - public function getIssetabilityDescriptor(): ?IssetabilityDescriptor - { - return $this->issetabilityDescriptor; - } - - /** - * Whether isset($expr) holds: folds the isset/empty/?? chain descriptor, or - * applies the leaf type check when the expression is not a chain link. - * - * @param callable(Type): ?bool $typeCallback + * The fully-resolved isset/empty/?? view of this expression on the asking + * scope: folds the chain descriptor, or builds a leaf resolution from the + * expression's own type when it is not a chain link (e.g. a method-call-rooted + * base like $this->getFoo()['x']). $useNativeTypes selects native vs phpdoc. */ - public function issetCheck(MutatingScope $scope, callable $typeCallback, ?bool $result = null): ?bool + public function getIssetabilityResolution(MutatingScope $scope, bool $useNativeTypes): IssetabilityResolution { if ($this->issetabilityDescriptor !== null) { - return $this->issetabilityDescriptor->check($scope, $typeCallback, $result); + return $this->issetabilityDescriptor->resolve($scope, $useNativeTypes, $this->expr); } - return $result ?? $typeCallback($this->getTypeForScope($scope)); - } - - public function issetCheckUndefined(MutatingScope $scope): ?bool - { - return $this->issetabilityDescriptor?->checkUndefined($scope); - } - - /** - * Whether $expr is definitely set-and-non-falsey (i.e. the negation of - * empty($expr)); null = maybe. EmptyHandler negates the result. - */ - public function empty(MutatingScope $scope): ?bool - { - return $this->issetCheck($scope, static function (Type $type): ?bool { - $isNull = $type->isNull(); - $isFalsey = $type->toBoolean()->isFalse(); - if ($isNull->maybe()) { - return null; - } - if ($isFalsey->maybe()) { - return null; - } - - if ($isNull->yes()) { - return $isFalsey->no(); - } + $type = $useNativeTypes ? $this->getNativeTypeForScope($scope) : $this->getTypeForScope($scope); - return !$isFalsey->yes(); - }); + return new IssetabilityResolution( + IssetabilityLinkInfo::leaf($type, $this->expr, $this->expr instanceof Expr\NullsafePropertyFetch), + null, + ); } /** diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index 869d4654d8c..c99351f2a25 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -6,21 +6,24 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticPropertyFetch; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; +use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Type; +use PHPStan\TrinaryLogic; +use PHPStan\Type\NeverType; /** - * The inside-out replacement for the AST re-walk in MutatingScope::issetCheck() - * (and, later, PHPStan\Rules\IssetCheck). Each chain-link ExpressionResult - * (variable / array dim fetch / property fetch) carries the descriptor for its - * own link plus references to the child ExpressionResult(s), so isset/empty/?? - * fold the chain by reading already-computed child results instead of - * re-traversing the AST and re-resolving types/reflections. + * The inside-out carrier for isset/empty/?? chains. Each chain-link + * ExpressionResult (variable / array dim fetch / property fetch) holds the + * descriptor for its own link plus references to the child ExpressionResult(s), + * built during the single pass like containsNullsafe. * - * Per-link types stay scope-recomputed (via the child results' getTypeForScope) - * because issetCheck answers at the asking scope, with phpdoc or native types - * depending on the scope. + * resolve() walks the chain once on the asking scope and produces an + * IssetabilityResolution of fully-resolved IssetabilityLinkInfo facts. The engine + * (IssetabilityResolution::isSet) and the rule (PHPStan\Rules\IssetCheck) read + * those facts; neither re-traverses the AST nor re-resolves types/reflections. */ final class IssetabilityDescriptor { @@ -64,62 +67,13 @@ public static function property(?ExpressionResult $innerResult, Closure $reflect return new self(self::KIND_PROPERTY, innerResult: $innerResult, reflectionResolver: $reflectionResolver, propertyFetch: $propertyFetch); } - public function isVariable(): bool - { - return $this->kind === self::KIND_VARIABLE; - } - - public function isOffset(): bool - { - return $this->kind === self::KIND_OFFSET; - } - - public function isProperty(): bool - { - return $this->kind === self::KIND_PROPERTY; - } - - public function getVariableName(): ?string - { - return $this->variableName; - } - - public function getVarResult(): ?ExpressionResult - { - return $this->varResult; - } - - public function getDimResult(): ?ExpressionResult - { - return $this->dimResult; - } - - public function getInnerResult(): ?ExpressionResult - { - return $this->innerResult; - } - - public function resolvePropertyReflection(MutatingScope $scope): ?FoundPropertyReflection - { - if ($this->reflectionResolver === null) { - throw new ShouldNotHappenException(); - } - - return ($this->reflectionResolver)($scope); - } - /** - * @return PropertyFetch|StaticPropertyFetch|null + * Walks the chain once on the asking scope, resolving every link's facts. + * $expr is the expression this descriptor belongs to (threaded by + * ExpressionResult::getIssetabilityResolution); $useNativeTypes selects native + * vs phpdoc types (the rule's treatPhpDocTypesAsCertain). */ - public function getPropertyFetch(): ?Expr - { - return $this->propertyFetch; - } - - /** - * @param callable(Type): ?bool $typeCallback - */ - public function check(MutatingScope $scope, callable $typeCallback, ?bool $result = null): ?bool + public function resolve(MutatingScope $scope, bool $useNativeTypes, Expr $expr): IssetabilityResolution { if ($this->kind === self::KIND_VARIABLE) { $variableName = $this->variableName; @@ -128,23 +82,11 @@ public function check(MutatingScope $scope, callable $typeCallback, ?bool $resul } $hasVariable = $scope->hasVariableType($variableName); - if ($hasVariable->maybe()) { - return null; - } - - if ($result === null) { - if ($hasVariable->yes()) { - if ($variableName === '_SESSION') { - return null; - } + $valueType = $hasVariable->yes() + ? ($useNativeTypes ? $scope->getNativeType($expr) : $scope->getType($expr)) + : new NeverType(); - return $typeCallback($scope->getVariableType($variableName)); - } - - return false; - } - - return $result; + return new IssetabilityResolution(IssetabilityLinkInfo::variable($variableName, $hasVariable, $valueType), null); } if ($this->kind === self::KIND_OFFSET) { @@ -154,29 +96,22 @@ public function check(MutatingScope $scope, callable $typeCallback, ?bool $resul throw new ShouldNotHappenException(); } - $type = $varResult->getTypeForScope($scope); - if (!$type->isOffsetAccessible()->yes()) { - return $result ?? $this->checkUndefinedInner($varResult, $scope); - } - - $dimType = $dimResult->getTypeForScope($scope); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if ($hasOffsetValue->no()) { - return false; - } - - // If offset cannot be null, store this error message and see if one of the earlier offsets is. - // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. - if ($hasOffsetValue->yes()) { - $result = $typeCallback($type->getOffsetValueType($dimType)); - - if ($result !== null) { - return $this->checkInner($varResult, $scope, $typeCallback, $result); - } - } - - // Has offset, it is nullable - return null; + $varType = $useNativeTypes ? $varResult->getNativeTypeForScope($scope) : $varResult->getTypeForScope($scope); + $dimType = $useNativeTypes ? $dimResult->getNativeTypeForScope($scope) : $dimResult->getTypeForScope($scope); + $hasOffsetValue = $varType->hasOffsetValueType($dimType); + $valueType = $hasOffsetValue->no() ? new NeverType() : $varType->getOffsetValueType($dimType); + + return new IssetabilityResolution( + IssetabilityLinkInfo::offset( + $varType->isOffsetAccessible(), + $hasOffsetValue, + $scope->hasExpressionType($expr)->yes(), + $varType, + $dimType, + $valueType, + ), + $varResult->getIssetabilityResolution($scope, $useNativeTypes), + ); } $reflectionResolver = $this->reflectionResolver; @@ -184,91 +119,44 @@ public function check(MutatingScope $scope, callable $typeCallback, ?bool $resul if ($reflectionResolver === null || $propertyFetch === null) { throw new ShouldNotHappenException(); } - $innerResult = $this->innerResult; + + $inner = $this->innerResult !== null ? $this->innerResult->getIssetabilityResolution($scope, $useNativeTypes) : null; $propertyReflection = $reflectionResolver($scope); if ($propertyReflection === null) { - return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; + return new IssetabilityResolution( + IssetabilityLinkInfo::property(null, $propertyFetch, false, false, TrinaryLogic::createNo(), new NeverType(), new NeverType(), false, false, false, false, false, false, false), + $inner, + ); } - if (!$propertyReflection->isNative()) { - return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; - } - - if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { - if (!$scope->hasExpressionType($propertyFetch)->yes()) { - $nativeReflection = $propertyReflection->getNativeReflection(); - if ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { - return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; - } - } - } - - if ($result !== null) { - return $innerResult !== null ? $this->checkInner($innerResult, $scope, $typeCallback, $result) : $result; - } - - $result = $typeCallback($propertyReflection->getWritableType()); - if ($result !== null && $innerResult !== null) { - return $this->checkInner($innerResult, $scope, $typeCallback, $result); - } - - return $result; - } - - public function checkUndefined(MutatingScope $scope): ?bool - { - if ($this->kind === self::KIND_VARIABLE) { - $variableName = $this->variableName; - if ($variableName === null) { - throw new ShouldNotHappenException(); - } - - $hasVariable = $scope->hasVariableType($variableName); - if (!$hasVariable->no()) { - return null; - } - - return false; - } - - if ($this->kind === self::KIND_OFFSET) { - $varResult = $this->varResult; - $dimResult = $this->dimResult; - if ($varResult === null || $dimResult === null) { - throw new ShouldNotHappenException(); - } - - $type = $varResult->getTypeForScope($scope); - if (!$type->isOffsetAccessible()->yes()) { - return $this->checkUndefinedInner($varResult, $scope); - } - - $dimType = $dimResult->getTypeForScope($scope); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if (!$hasOffsetValue->no()) { - return $this->checkUndefinedInner($varResult, $scope); - } - - return false; - } - - $innerResult = $this->innerResult; - - return $innerResult !== null ? $this->checkUndefinedInner($innerResult, $scope) : null; - } - - /** - * @param callable(Type): ?bool $typeCallback - */ - private function checkInner(ExpressionResult $inner, MutatingScope $scope, callable $typeCallback, ?bool $result): ?bool - { - return $inner->issetCheck($scope, $typeCallback, $result); - } - - private function checkUndefinedInner(ExpressionResult $inner, MutatingScope $scope): ?bool - { - return $inner->issetCheckUndefined($scope); + $hasNativeType = $propertyReflection->hasNativeType(); + $nativeReflection = $propertyReflection->getNativeReflection(); + $initializedThisProperty = $propertyFetch instanceof PropertyFetch + && $propertyFetch->name instanceof Identifier + && $propertyFetch->var instanceof Variable + && $propertyFetch->var->name === 'this' + && $scope->hasExpressionType(new PropertyInitializationExpr($propertyReflection->getName()))->yes(); + + return new IssetabilityResolution( + IssetabilityLinkInfo::property( + $propertyReflection, + $propertyFetch, + $propertyReflection->isNative(), + $hasNativeType, + $propertyReflection->isVirtual(), + $propertyReflection->getWritableType(), + $hasNativeType ? $propertyReflection->getNativeType() : new NeverType(), + $scope->hasExpressionType($propertyFetch)->yes(), + $initializedThisProperty, + $nativeReflection !== null, + $nativeReflection !== null && $nativeReflection->isPromoted(), + $nativeReflection !== null && $nativeReflection->isReadOnly(), + $nativeReflection !== null && $nativeReflection->isHooked(), + $nativeReflection !== null && $nativeReflection->getNativeReflection()->hasDefaultValue(), + ), + $inner, + ); } } diff --git a/src/Analyser/IssetabilityLinkInfo.php b/src/Analyser/IssetabilityLinkInfo.php new file mode 100644 index 00000000000..a347a8039fd --- /dev/null +++ b/src/Analyser/IssetabilityLinkInfo.php @@ -0,0 +1,292 @@ +kind === self::KIND_VARIABLE; + } + + public function isOffset(): bool + { + return $this->kind === self::KIND_OFFSET; + } + + public function isProperty(): bool + { + return $this->kind === self::KIND_PROPERTY; + } + + public function getVariableName(): string + { + if ($this->variableName === null) { + throw new ShouldNotHappenException(); + } + + return $this->variableName; + } + + public function getHasVariable(): TrinaryLogic + { + if ($this->hasVariable === null) { + throw new ShouldNotHappenException(); + } + + return $this->hasVariable; + } + + /** The type the operator's callback inspects: variable type, offset value type, property writable type, or leaf type. */ + public function getValueType(): Type + { + if ($this->valueType === null) { + throw new ShouldNotHappenException(); + } + + return $this->valueType; + } + + public function getIsOffsetAccessible(): TrinaryLogic + { + if ($this->isOffsetAccessible === null) { + throw new ShouldNotHappenException(); + } + + return $this->isOffsetAccessible; + } + + public function getHasOffsetValue(): TrinaryLogic + { + if ($this->hasOffsetValue === null) { + throw new ShouldNotHappenException(); + } + + return $this->hasOffsetValue; + } + + public function hasExpressionTypeOfExpr(): bool + { + return $this->hasExpressionTypeOfExpr; + } + + public function getVarType(): Type + { + if ($this->varType === null) { + throw new ShouldNotHappenException(); + } + + return $this->varType; + } + + public function getDimType(): Type + { + if ($this->dimType === null) { + throw new ShouldNotHappenException(); + } + + return $this->dimType; + } + + public function getPropertyReflection(): ?FoundPropertyReflection + { + return $this->propertyReflection; + } + + /** + * @return Expr\PropertyFetch|Expr\StaticPropertyFetch + */ + public function getPropertyFetch(): Expr + { + if (!$this->propertyFetch instanceof Expr\PropertyFetch && !$this->propertyFetch instanceof Expr\StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + return $this->propertyFetch; + } + + public function isReflectionNative(): bool + { + return $this->reflectionNative; + } + + public function hasNativeType(): bool + { + return $this->hasNativeType; + } + + public function isVirtual(): TrinaryLogic + { + if ($this->isVirtual === null) { + throw new ShouldNotHappenException(); + } + + return $this->isVirtual; + } + + public function getNativeType(): Type + { + if ($this->nativeType === null) { + throw new ShouldNotHappenException(); + } + + return $this->nativeType; + } + + public function hasExpressionTypeOfFetch(): bool + { + return $this->hasExpressionTypeOfFetch; + } + + public function isInitializedThisProperty(): bool + { + return $this->initializedThisProperty; + } + + public function nativeReflectionExists(): bool + { + return $this->nativeReflectionExists; + } + + public function nativeIsPromoted(): bool + { + return $this->nativeIsPromoted; + } + + public function nativeIsReadOnly(): bool + { + return $this->nativeIsReadOnly; + } + + public function nativeIsHooked(): bool + { + return $this->nativeIsHooked; + } + + public function nativeHasDefaultValue(): bool + { + return $this->nativeHasDefaultValue; + } + + public function getLeafExpr(): Expr + { + if ($this->leafExpr === null) { + throw new ShouldNotHappenException(); + } + + return $this->leafExpr; + } + + public function leafIsNullsafePropertyFetch(): bool + { + return $this->leafIsNullsafePropertyFetch; + } + +} diff --git a/src/Analyser/IssetabilityResolution.php b/src/Analyser/IssetabilityResolution.php new file mode 100644 index 00000000000..cbffa96d0c9 --- /dev/null +++ b/src/Analyser/IssetabilityResolution.php @@ -0,0 +1,176 @@ +link; + } + + public function getInner(): ?IssetabilityResolution + { + return $this->inner; + } + + /** + * Whether isset() of the whole chain holds: null = maybe (resolves to bool), + * true/false = the typeCallback's verdict on the leaf type threaded outward + * over the chain's set-ness. Mirrors the former MutatingScope::issetCheck(). + * + * @param callable(Type): ?bool $typeCallback + */ + public function isSet(callable $typeCallback, ?bool $result = null): ?bool + { + $link = $this->link; + + if ($link->isVariable()) { + $hasVariable = $link->getHasVariable(); + if ($hasVariable->maybe()) { + return null; + } + + if ($result === null) { + if ($hasVariable->yes()) { + if ($link->getVariableName() === '_SESSION') { + return null; + } + + return $typeCallback($link->getValueType()); + } + + return false; + } + + return $result; + } + + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $result ?? $this->inner?->isSetUndefined(); + } + + $hasOffsetValue = $link->getHasOffsetValue(); + if ($hasOffsetValue->no()) { + return false; + } + + // If offset cannot be null, store this verdict and see if one of the earlier + // offsets is. E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR + // b OR c might be null. + if ($hasOffsetValue->yes()) { + $result = $typeCallback($link->getValueType()); + + if ($result !== null) { + return $this->inner !== null ? $this->inner->isSet($typeCallback, $result) : $result; + } + } + + // Has offset, it is nullable + return null; + } + + if ($link->isProperty()) { + if ($link->getPropertyReflection() === null || !$link->isReflectionNative()) { + return $this->inner?->isSetUndefined(); + } + + if ( + $link->hasNativeType() + && !$link->isVirtual()->yes() + && !$link->hasExpressionTypeOfFetch() + && (!$link->nativeReflectionExists() || !$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) + ) { + return $this->inner?->isSetUndefined(); + } + + if ($result !== null) { + return $this->inner !== null ? $this->inner->isSet($typeCallback, $result) : $result; + } + + $result = $typeCallback($link->getValueType()); + if ($result !== null && $this->inner !== null) { + return $this->inner->isSet($typeCallback, $result); + } + + return $result; + } + + // leaf + return $result ?? $typeCallback($link->getValueType()); + } + + private function isSetUndefined(): ?bool + { + $link = $this->link; + + if ($link->isVariable()) { + if (!$link->getHasVariable()->no()) { + return null; + } + + return false; + } + + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $this->inner?->isSetUndefined(); + } + + if (!$link->getHasOffsetValue()->no()) { + return $this->inner?->isSetUndefined(); + } + + return false; + } + + if ($link->isProperty()) { + return $this->inner?->isSetUndefined(); + } + + return null; + } + + /** + * Whether empty() of the whole chain is surely false (i.e. set and not falsy); + * null = maybe. EmptyHandler negates the result. + */ + public function notEmpty(): ?bool + { + return $this->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + } + +} diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c17ea2edff1..ca95595915d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1270,219 +1270,6 @@ public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage return $this->expressionResultStorageStack->getCurrent(); } - /** - * The isset/empty/?? chain descriptor PHPStan\Rules\IssetCheck folds. Reads - * it from the current expression-result storage; when the rule asks before - * the engine has stored the expression's result (the rule callback fires - * before the chain-link handlers run), the expression is processed on demand - * just like resolveTypeOfNewWorldHandlerNode(). - * - * @internal - */ - public function getIssetabilityDescriptor(Expr $expr): ?IssetabilityDescriptor - { - $scope = $this->toMutatingScope(); - $storage = $this->expressionResultStorageStack->getCurrent(); - $onDemandStorage = $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(); - if ($storage !== null) { - $result = $storage->findExpressionResult($expr); - if ($result !== null) { - $descriptor = $result->getIssetabilityDescriptor(); - if ($descriptor !== null) { - return $descriptor; - } - - // a placeholder result (e.g. the var of `$x['k'] ??= …`, stored - // as an assignment target) carries no descriptor; re-process on a - // fresh storage so the placeholder doesn't shadow the real one - // (processExprOnDemand returns stored results, incl. the placeholder) - $onDemandStorage = new ExpressionResultStorage(); - } - } - - $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( - $expr, - $scope, - $onDemandStorage, - ); - - return $onDemandResult->getIssetabilityDescriptor(); - } - - /** - * @param callable(Type): ?bool $typeCallback - */ - public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = null): ?bool - { - // mirrored in PHPStan\Rules\IssetCheck - $storage = $this->expressionResultStorageStack->getCurrent(); - if ($storage !== null) { - $exprResult = $storage->findExpressionResult($expr); - if ($exprResult !== null && $exprResult->getIssetabilityDescriptor() !== null) { - return $exprResult->issetCheck($this, $typeCallback, $result); - } - } - - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $this->hasVariableType($expr->name); - if ($hasVariable->maybe()) { - return null; - } - - if ($result === null) { - if ($hasVariable->yes()) { - if ($expr->name === '_SESSION') { - return null; - } - - return $typeCallback($this->getVariableType($expr->name)); - } - - return false; - } - - return $result; - } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->getType($expr->var); - if (!$type->isOffsetAccessible()->yes()) { - return $result ?? $this->issetCheckUndefined($expr->var); - } - - $dimType = $this->getType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if ($hasOffsetValue->no()) { - return false; - } - - // If offset cannot be null, store this error message and see if one of the earlier offsets is. - // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. - if ($hasOffsetValue->yes()) { - $result = $typeCallback($type->getOffsetValueType($dimType)); - - if ($result !== null) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - } - - // Has offset, it is nullable - return null; - - } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { - - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); - - if ($propertyReflection === null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } - - return null; - } - - if (!$propertyReflection->isNative()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } - - return null; - } - - if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { - if (!$this->hasExpressionType($expr)->yes()) { - $nativeReflection = $propertyReflection->getNativeReflection(); - if ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } - - return null; - } - } - } - - if ($result !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheck($expr->class, $typeCallback, $result); - } - - return $result; - } - - $result = $typeCallback($propertyReflection->getWritableType()); - if ($result !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheck($expr->class, $typeCallback, $result); - } - } - - return $result; - } - - if ($result !== null) { - return $result; - } - - return $typeCallback($this->getType($expr)); - } - - private function issetCheckUndefined(Expr $expr): ?bool - { - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $this->hasVariableType($expr->name); - if (!$hasVariable->no()) { - return null; - } - - return false; - } - - if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->getType($expr->var); - if (!$type->isOffsetAccessible()->yes()) { - return $this->issetCheckUndefined($expr->var); - } - - $dimType = $this->getType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - - if (!$hasOffsetValue->no()) { - return $this->issetCheckUndefined($expr->var); - } - - return false; - } - - if ($expr instanceof Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } - - return null; - } - /** @api */ public function getNativeType(Expr $expr): Type { diff --git a/src/Node/CoalesceExpressionNode.php b/src/Node/CoalesceExpressionNode.php new file mode 100644 index 00000000000..ba3398f4200 --- /dev/null +++ b/src/Node/CoalesceExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + public function getSubjectResult(): ExpressionResult + { + return $this->subjectResult; + } + + public function getOperatorDescription(): string + { + return $this->operatorDescription; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_CoalesceExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/EmptyExpressionNode.php b/src/Node/EmptyExpressionNode.php new file mode 100644 index 00000000000..e6685101a76 --- /dev/null +++ b/src/Node/EmptyExpressionNode.php @@ -0,0 +1,45 @@ +getAttributes()); + } + + public function getExprResult(): ExpressionResult + { + return $this->exprResult; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_EmptyExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/IssetExpressionNode.php b/src/Node/IssetExpressionNode.php new file mode 100644 index 00000000000..6c8bc870198 --- /dev/null +++ b/src/Node/IssetExpressionNode.php @@ -0,0 +1,51 @@ +getAttributes()); + } + + /** + * @return ExpressionResult[] + */ + public function getVarResults(): array + { + return $this->varResults; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_IssetExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index e0a2ccf409c..eff4ed98840 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -2,17 +2,15 @@ namespace PHPStan\Rules; -use PhpParser\Node; -use PhpParser\Node\Expr; +use PhpParser\Node\Expr\NullsafePropertyFetch; +use PhpParser\Node\Identifier; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\IssetabilityDescriptor; +use PHPStan\Analyser\IssetabilityResolution; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; @@ -20,6 +18,11 @@ use function str_starts_with; /** + * Renders the isset/empty/?? "does it make sense" errors from the single + * IssetabilityResolution the engine already computed. The chain is walked and + * resolved once (IssetabilityDescriptor::resolve); this only projects the + * resolved facts into messages - it never re-walks the AST nor re-resolves types. + * * @phpstan-type ErrorIdentifier = 'empty'|'isset'|'nullCoalesce' */ #[AutowiredService] @@ -40,42 +43,40 @@ public function __construct( * @param ErrorIdentifier $identifier * @param callable(Type): ?string $typeMessageCallback */ - public function check(Expr $expr, Scope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error = null): ?IdentifierRuleError + public function check(ExpressionResult $exprResult, Scope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback): ?IdentifierRuleError { $mutatingScope = $scope->toMutatingScope(); + $resolution = $exprResult->getIssetabilityResolution($mutatingScope, !$this->treatPhpDocTypesAsCertain); - return $this->doCheck($mutatingScope->getIssetabilityDescriptor($expr), $expr, $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); + return $this->doCheck($resolution, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, null); } /** * @param ErrorIdentifier $identifier * @param callable(Type): ?string $typeMessageCallback */ - private function doCheck(?IssetabilityDescriptor $descriptor, Expr $expr, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error): ?IdentifierRuleError + private function doCheck(IssetabilityResolution $resolution, MutatingScope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error): ?IdentifierRuleError { - // folds PHPStan\Analyser\IssetabilityDescriptor; mirrors PHPStan\Analyser\MutatingScope::issetCheck() - if ($descriptor !== null && $descriptor->isVariable()) { - $variableName = $descriptor->getVariableName(); - if ($variableName === null) { - throw new ShouldNotHappenException(); - } + $link = $resolution->getLink(); + $inner = $resolution->getInner(); - $hasVariable = $scope->hasVariableType($variableName); + if ($link->isVariable()) { + $hasVariable = $link->getHasVariable(); if ($hasVariable->maybe()) { return null; } if ($error === null) { if ($hasVariable->yes()) { - if ($variableName === '_SESSION') { + if ($link->getVariableName() === '_SESSION') { return null; } - $type = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr) : $scope->getScopeNativeType($expr); + $type = $link->getValueType(); if (!$type instanceof NeverType) { return $this->generateError( $type, - sprintf('Variable $%s %s always exists and', $variableName, $operatorDescription), + sprintf('Variable $%s %s always exists and', $link->getVariableName(), $operatorDescription), $typeMessageCallback, $identifier, 'variable', @@ -83,30 +84,22 @@ private function doCheck(?IssetabilityDescriptor $descriptor, Expr $expr, Scope } } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $variableName, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $link->getVariableName(), $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } return $error; - } elseif ($descriptor !== null && $descriptor->isOffset()) { - $varResult = $descriptor->getVarResult(); - $dimResult = $descriptor->getDimResult(); - if ($varResult === null || $dimResult === null) { - throw new ShouldNotHappenException(); - } + } - $type = $this->treatPhpDocTypesAsCertain - ? $varResult->getTypeForScope($mutatingScope) - : $varResult->getNativeTypeForScope($mutatingScope); - if (!$type->isOffsetAccessible()->yes()) { - return $error ?? $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); + if ($link->isOffset()) { + $type = $link->getVarType(); + if (!$link->getIsOffsetAccessible()->yes()) { + return $error ?? $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - $dimType = $this->treatPhpDocTypesAsCertain - ? $dimResult->getTypeForScope($mutatingScope) - : $dimResult->getNativeTypeForScope($mutatingScope); - $hasOffsetValue = $type->hasOffsetValueType($dimType); + $dimType = $link->getDimType(); + $hasOffsetValue = $link->getHasOffsetValue(); if ($hasOffsetValue->no()) { if (!$this->checkAdvancedIsset) { return null; @@ -124,12 +117,12 @@ private function doCheck(?IssetabilityDescriptor $descriptor, Expr $expr, Scope // If offset cannot be null, store this error message and see if one of the earlier offsets is. // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. - if ($hasOffsetValue->yes() || $scope->hasExpressionType($expr)->yes()) { + if ($hasOffsetValue->yes() || $link->hasExpressionTypeOfExpr()) { if (!$this->checkAdvancedIsset) { return null; } - $error ??= $this->generateError($type->getOffsetValueType($dimType), sprintf( + $error ??= $this->generateError($link->getValueType(), sprintf( 'Offset %s on %s %s always exists and', $dimType->describe(VerbosityLevel::value()), $type->describe(VerbosityLevel::value()), @@ -137,50 +130,27 @@ private function doCheck(?IssetabilityDescriptor $descriptor, Expr $expr, Scope ), $typeMessageCallback, $identifier, 'offset'); if ($error !== null) { - return $this->doCheck($varResult->getIssetabilityDescriptor(), $varResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); + return $inner !== null ? $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error) : $error; } } // Has offset, it is nullable return null; + } - } elseif ($descriptor !== null && $descriptor->isProperty()) { - - $propertyFetch = $descriptor->getPropertyFetch(); - if ($propertyFetch === null) { - throw new ShouldNotHappenException(); - } - $innerResult = $descriptor->getInnerResult(); - - $propertyReflection = $descriptor->resolvePropertyReflection($mutatingScope); - - if ($propertyReflection === null) { - return $innerResult !== null - ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) - : null; - } + if ($link->isProperty()) { + $reflection = $link->getPropertyReflection(); + $propertyFetch = $link->getPropertyFetch(); - if (!$propertyReflection->isNative()) { - return $innerResult !== null - ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) - : null; + if ($reflection === null || !$link->isReflectionNative()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { - if ( - $propertyFetch instanceof Node\Expr\PropertyFetch - && $propertyFetch->name instanceof Node\Identifier - && $propertyFetch->var instanceof Expr\Variable - && $propertyFetch->var->name === 'this' - && $scope->hasExpressionType(new PropertyInitializationExpr($propertyReflection->getName()))->yes() - ) { + if ($link->hasNativeType() && !$link->isVirtual()->yes()) { + if ($link->isInitializedThisProperty()) { return $this->generateError( - $propertyReflection->getNativeType(), - sprintf( - '%s %s', - $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $propertyFetch), - $operatorDescription, - ), + $link->getNativeType(), + sprintf('%s %s', $this->propertyDescriptor->describeProperty($reflection, $scope, $propertyFetch), $operatorDescription), static function (Type $type) use ($typeMessageCallback): ?string { $originalMessage = $typeMessageCallback($type); if ($originalMessage === null) { @@ -198,46 +168,43 @@ static function (Type $type) use ($typeMessageCallback): ?string { ); } - if (!$scope->hasExpressionType($propertyFetch)->yes()) { - $nativeReflection = $propertyReflection->getNativeReflection(); - if ( - $nativeReflection !== null - && !$nativeReflection->getNativeReflection()->hasDefaultValue() - && (!$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) - ) { - return null; - } + if ( + !$link->hasExpressionTypeOfFetch() + && $link->nativeReflectionExists() + && !$link->nativeHasDefaultValue() + && (!$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) + ) { + return null; } } - $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $propertyFetch); - $propertyType = $propertyReflection->getWritableType(); + $propertyDescription = $this->propertyDescriptor->describeProperty($reflection, $scope, $propertyFetch); + $propertyType = $reflection->getWritableType(); if ($error !== null) { - return $innerResult !== null - ? $this->doCheck($innerResult->getIssetabilityDescriptor(), $innerResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error) + return $inner !== null + ? $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error) : $error; } if (!$this->checkAdvancedIsset) { - return $innerResult !== null - ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) - : null; + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } $error = $this->generateError( - $propertyReflection->getWritableType(), + $propertyType, sprintf('%s (%s) %s', $propertyDescription, $propertyType->describe(VerbosityLevel::typeOnly()), $operatorDescription), $typeMessageCallback, $identifier, 'property', ); - if ($error !== null && $innerResult !== null) { - return $this->doCheck($innerResult->getIssetabilityDescriptor(), $innerResult->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, $error); + if ($error !== null && $inner !== null) { + return $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); } return $error; } + // leaf - an arbitrary base expression that is not a chain link if ($error !== null) { return $error; } @@ -247,7 +214,7 @@ static function (Type $type) use ($typeMessageCallback): ?string { } $error = $this->generateError( - $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr) : $scope->getScopeNativeType($expr), + $link->getValueType(), sprintf('Expression %s', $operatorDescription), $typeMessageCallback, $identifier, @@ -257,9 +224,10 @@ static function (Type $type) use ($typeMessageCallback): ?string { return $error; } - if ($expr instanceof Expr\NullsafePropertyFetch) { - if ($expr->name instanceof Node\Identifier) { - return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $expr->name->name, $operatorDescription)) + if ($link->leafIsNullsafePropertyFetch()) { + $leafExpr = $link->getLeafExpr(); + if ($leafExpr instanceof NullsafePropertyFetch && $leafExpr->name instanceof Identifier) { + return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $leafExpr->name->name, $operatorDescription)) ->identifier('nullsafe.neverNull') ->build(); } @@ -272,69 +240,46 @@ static function (Type $type) use ($typeMessageCallback): ?string { return null; } - /** - * @param ErrorIdentifier $identifier - */ - private function checkUndefinedInner(ExpressionResult $inner, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier): ?IdentifierRuleError + private function checkUndefinedInner(?IssetabilityResolution $resolution, MutatingScope $scope, string $operatorDescription, string $identifier): ?IdentifierRuleError { - return $this->checkUndefined($inner->getIssetabilityDescriptor(), $inner->getExpr(), $scope, $mutatingScope, $operatorDescription, $identifier); - } + if ($resolution === null) { + return null; + } - /** - * @param ErrorIdentifier $identifier - */ - private function checkUndefined(?IssetabilityDescriptor $descriptor, Expr $expr, Scope $scope, MutatingScope $mutatingScope, string $operatorDescription, string $identifier): ?IdentifierRuleError - { - if ($descriptor !== null && $descriptor->isVariable()) { - $variableName = $descriptor->getVariableName(); - if ($variableName === null) { - throw new ShouldNotHappenException(); - } + $link = $resolution->getLink(); + $inner = $resolution->getInner(); - $hasVariable = $scope->hasVariableType($variableName); - if (!$hasVariable->no()) { + if ($link->isVariable()) { + if (!$link->getHasVariable()->no()) { return null; } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $variableName, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $link->getVariableName(), $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } - if ($descriptor !== null && $descriptor->isOffset()) { - $varResult = $descriptor->getVarResult(); - $dimResult = $descriptor->getDimResult(); - if ($varResult === null || $dimResult === null) { - throw new ShouldNotHappenException(); - } - - $type = $this->treatPhpDocTypesAsCertain ? $varResult->getTypeForScope($mutatingScope) : $varResult->getNativeTypeForScope($mutatingScope); - $dimType = $this->treatPhpDocTypesAsCertain ? $dimResult->getTypeForScope($mutatingScope) : $dimResult->getNativeTypeForScope($mutatingScope); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if (!$type->isOffsetAccessible()->yes()) { - return $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - if (!$hasOffsetValue->no()) { - return $this->checkUndefinedInner($varResult, $scope, $mutatingScope, $operatorDescription, $identifier); + if (!$link->getHasOffsetValue()->no()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } return RuleErrorBuilder::message( sprintf( 'Offset %s on %s %s does not exist.', - $dimType->describe(VerbosityLevel::value()), - $type->describe(VerbosityLevel::value()), + $link->getDimType()->describe(VerbosityLevel::value()), + $link->getVarType()->describe(VerbosityLevel::value()), $operatorDescription, ), )->identifier(sprintf('%s.offset', $identifier))->build(); } - if ($descriptor !== null && $descriptor->isProperty()) { - $innerResult = $descriptor->getInnerResult(); - - return $innerResult !== null - ? $this->checkUndefinedInner($innerResult, $scope, $mutatingScope, $operatorDescription, $identifier) - : null; + if ($link->isProperty()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } return null; diff --git a/src/Rules/Variables/EmptyRule.php b/src/Rules/Variables/EmptyRule.php index d1656a3be27..b884cebddc3 100644 --- a/src/Rules/Variables/EmptyRule.php +++ b/src/Rules/Variables/EmptyRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\EmptyExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class EmptyRule implements Rule @@ -22,12 +23,12 @@ public function __construct(private IssetCheck $issetCheck) public function getNodeType(): string { - return Node\Expr\Empty_::class; + return EmptyExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $error = $this->issetCheck->check($node->expr, $scope, 'in empty()', 'empty', static function (Type $type): ?string { + $error = $this->issetCheck->check($node->getExprResult(), $scope, 'in empty()', 'empty', static function (Type $type): ?string { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Rules/Variables/IssetRule.php b/src/Rules/Variables/IssetRule.php index 839241a169b..cd9300c2dc5 100644 --- a/src/Rules/Variables/IssetRule.php +++ b/src/Rules/Variables/IssetRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\IssetExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class IssetRule implements Rule @@ -22,14 +23,14 @@ public function __construct(private IssetCheck $issetCheck) public function getNodeType(): string { - return Node\Expr\Isset_::class; + return IssetExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { $messages = []; - foreach ($node->vars as $var) { - $error = $this->issetCheck->check($var, $scope, 'in isset()', 'isset', static function (Type $type): ?string { + foreach ($node->getVarResults() as $varResult) { + $error = $this->issetCheck->check($varResult, $scope, 'in isset()', 'isset', static function (Type $type): ?string { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Rules/Variables/NullCoalesceRule.php b/src/Rules/Variables/NullCoalesceRule.php index 79941d0293d..f0365d59216 100644 --- a/src/Rules/Variables/NullCoalesceRule.php +++ b/src/Rules/Variables/NullCoalesceRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class NullCoalesceRule implements Rule @@ -22,31 +23,29 @@ public function __construct(private IssetCheck $issetCheck) public function getNodeType(): string { - return Node\Expr::class; + return CoalesceExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $typeMessageCallback = static function (Type $type): ?string { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - if ($isNull->yes()) { - return 'is always null'; - } - - return 'is not nullable'; - }; - - if ($node instanceof Node\Expr\BinaryOp\Coalesce) { - $error = $this->issetCheck->check($node->left, $scope, 'on left side of ??', 'nullCoalesce', $typeMessageCallback); - } elseif ($node instanceof Node\Expr\AssignOp\Coalesce) { - $error = $this->issetCheck->check($node->var, $scope, 'on left side of ??=', 'nullCoalesce', $typeMessageCallback); - } else { - return []; - } + $error = $this->issetCheck->check( + $node->getSubjectResult(), + $scope, + $node->getOperatorDescription(), + 'nullCoalesce', + static function (Type $type): ?string { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + if ($isNull->yes()) { + return 'is always null'; + } + + return 'is not nullable'; + }, + ); if ($error === null) { return []; From 8f1032dac4bfd83df59fe47addc08ba839c8db2f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 11:58:58 +0200 Subject: [PATCH 115/378] Resolve isset() narrowing types from computed results, not Scope::getType IssetHandler's narrowing is inlined into the specifyTypesCallback and reads each chain link's type from the ExpressionResults captured during processExpr (via getTypeForScope, which honours narrowing) instead of re-walking through Scope::getType, so already-processed expressions are not re-processed. --- src/Analyser/ExprHandler/IssetHandler.php | 447 ++++++++++++---------- 1 file changed, 241 insertions(+), 206 deletions(-) diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index a27e2dcaf6a..40b9b5466e8 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -50,6 +50,7 @@ use function array_shift; use function count; use function is_string; +use function spl_object_id; /** * @implements ExprHandler @@ -96,7 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $scope->getType($var->var); + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } @@ -117,6 +118,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); } + // The subjects and their chain links were just processed, so their + // ExpressionResults are in the storage; capture them (the results, not the + // storage - no reference cycle) so the narrowing reads their types via + // getTypeForScope() instead of re-walking through Scope::getType(). + $chainResults = []; + foreach ($expr->vars as $var) { + $this->captureChainResults($var, $storage, $chainResults); + } + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); return $this->expressionResultFactory->create( @@ -155,251 +165,276 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType($issetResult); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->specifyTypes($s, $expr, $context, $varResults), - ); - } - - /** - * @param ExpressionResult[] $varResults - */ - private function specifyTypes(MutatingScope $scope, Isset_ $expr, TypeSpecifierContext $context, array $varResults): SpecifiedTypes - { - if (count($expr->vars) === 0 || $context->null()) { - return $this->typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - // rewrite multi param isset() to and-chained single param isset() - if (count($expr->vars) > 1) { - $issets = []; - foreach ($expr->vars as $var) { - $issets[] = new Isset_([$var], $expr->getAttributes()); - } - - $first = array_shift($issets); - $andChain = null; - foreach ($issets as $isset) { - if ($andChain === null) { - $andChain = new BooleanAnd($first, $isset); - continue; + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver): SpecifiedTypes { + // type of an already-processed chain link, read from its captured + // result (re-evaluated on the asking scope, honouring narrowing) - + // never re-walked through the scope + $readType = static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { + $result = $chainResults[spl_object_id($e)] ?? null; + + return $result !== null ? $result->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); + }; + + if (count($expr->vars) === 0 || $context->null()) { + return $this->typeSpecifier->specifyDefaultTypes($s, $expr, $context); } - $andChain = new BooleanAnd($andChain, $isset); - } + // rewrite multi param isset() to and-chained single param isset() + if (count($expr->vars) > 1) { + $issets = []; + foreach ($expr->vars as $var) { + $issets[] = new Isset_([$var], $expr->getAttributes()); + } - if ($andChain === null) { - throw new ShouldNotHappenException(); - } + $first = array_shift($issets); + $andChain = null; + foreach ($issets as $isset) { + if ($andChain === null) { + $andChain = new BooleanAnd($first, $isset); + continue; + } - return $this->typeSpecifier->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); - } + $andChain = new BooleanAnd($andChain, $isset); + } - $issetExpr = $expr->vars[0]; + if ($andChain === null) { + throw new ShouldNotHappenException(); + } - if (!$context->true()) { - $isset = $varResults[0]->getIssetabilityResolution($scope, false)->isSet(static fn (): bool => true); + return $this->typeSpecifier->specifyTypesInCondition($s, $andChain, $context)->setRootExpr($expr); + } - if ($isset === false) { - return new SpecifiedTypes(); - } + $issetExpr = $expr->vars[0]; - $type = $scope->getType($issetExpr); - $isNullable = !$type->isNull()->no(); - $exprType = $this->typeSpecifier->create( - $issetExpr, - new NullType(), - $context->negate(), - $scope, - )->setRootExpr($expr); + if (!$context->true()) { + $isset = $varResults[0]->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); - if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { - if ($isset === true) { - if ($isNullable) { - return $exprType; + if ($isset === false) { + return new SpecifiedTypes(); } - // variable cannot exist in !isset() - return $exprType->unionWith($this->typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - ))->setRootExpr($expr); - } - - if ($isNullable) { - // reduces variable certainty to maybe - return $exprType->unionWith($this->typeSpecifier->create( - new IssetExpr($issetExpr), + $type = $readType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->typeSpecifier->create( + $issetExpr, new NullType(), $context->negate(), - $scope, - ))->setRootExpr($expr); - } + $s, + )->setRootExpr($expr); - // variable cannot exist in !isset() - return $this->typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - )->setRootExpr($expr); - } - - if ($isNullable && $isset === true) { - return $exprType; - } - - if ( - $issetExpr instanceof ArrayDimFetch - && $issetExpr->dim !== null - ) { - $varType = $scope->getType($issetExpr->var); - if (!$varType instanceof MixedType) { - $dimType = $scope->getType($issetExpr->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $constantArrays = $varType->getConstantArrays(); - $typesToRemove = []; - foreach ($constantArrays as $constantArray) { - $hasOffset = $constantArray->hasOffsetValueType($dimType); - if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { - continue; + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; } - $typesToRemove[] = $constantArray; + // variable cannot exist in !isset() + return $exprType->unionWith($this->typeSpecifier->create( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + ))->setRootExpr($expr); } - if ($typesToRemove !== []) { - $typeToRemove = TypeCombinator::union(...$typesToRemove); + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->typeSpecifier->create( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $s, + ))->setRootExpr($expr); + } - $result = $this->typeSpecifier->create( - $issetExpr->var, - $typeToRemove, - TypeSpecifierContext::createFalse(), - $scope, - )->setRootExpr($expr); + // variable cannot exist in !isset() + return $this->typeSpecifier->create( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + )->setRootExpr($expr); + } - if ($scope->hasExpressionType($issetExpr->var)->maybe()) { - $result = $result->unionWith( - $this->typeSpecifier->create( - new IssetExpr($issetExpr->var), - new NullType(), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } + if ($isNullable && $isset === true) { + return $exprType; + } - return $result; + if ( + $issetExpr instanceof ArrayDimFetch + && $issetExpr->dim !== null + ) { + $varType = $readType($issetExpr->var); + if (!$varType instanceof MixedType) { + $dimType = $readType($issetExpr->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $constantArrays = $varType->getConstantArrays(); + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + $hasOffset = $constantArray->hasOffsetValueType($dimType); + if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + continue; + } + + $typesToRemove[] = $constantArray; + } + + if ($typesToRemove !== []) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); + + $result = $this->typeSpecifier->create( + $issetExpr->var, + $typeToRemove, + TypeSpecifierContext::createFalse(), + $s, + )->setRootExpr($expr); + + if ($s->hasExpressionType($issetExpr->var)->maybe()) { + $result = $result->unionWith( + $this->typeSpecifier->create( + new IssetExpr($issetExpr->var), + new NullType(), + TypeSpecifierContext::createTruthy(), + $s, + )->setRootExpr($expr), + ); + } + + return $result; + } + } } } - } - } - return new SpecifiedTypes(); - } + return new SpecifiedTypes(); + } - $tmpVars = [$issetExpr]; - while ( - $issetExpr instanceof ArrayDimFetch - || $issetExpr instanceof PropertyFetch - || ( - $issetExpr instanceof StaticPropertyFetch - && $issetExpr->class instanceof Expr - ) - ) { - if ($issetExpr instanceof StaticPropertyFetch) { - /** @var Expr $issetExpr */ - $issetExpr = $issetExpr->class; - } else { - $issetExpr = $issetExpr->var; - } - $tmpVars[] = $issetExpr; - } - $vars = array_reverse($tmpVars); + $tmpVars = [$issetExpr]; + while ( + $issetExpr instanceof ArrayDimFetch + || $issetExpr instanceof PropertyFetch + || ( + $issetExpr instanceof StaticPropertyFetch + && $issetExpr->class instanceof Expr + ) + ) { + if ($issetExpr instanceof StaticPropertyFetch) { + /** @var Expr $issetExpr */ + $issetExpr = $issetExpr->class; + } else { + $issetExpr = $issetExpr->var; + } + $tmpVars[] = $issetExpr; + } + $vars = array_reverse($tmpVars); - $types = new SpecifiedTypes(); - foreach ($vars as $var) { + $types = new SpecifiedTypes(); + foreach ($vars as $var) { - if ($var instanceof Expr\Variable && is_string($var->name)) { - if ($scope->hasVariableType($var->name)->no()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } + if ($var instanceof Expr\Variable && is_string($var->name)) { + if ($s->hasVariableType($var->name)->no()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + } - if ( - $var instanceof ArrayDimFetch - && $var->dim !== null - && !$scope->getType($var->var) instanceof MixedType - ) { - $dimType = $scope->getType($var->dim); + if ( + $var instanceof ArrayDimFetch + && $var->dim !== null + && !$readType($var->var) instanceof MixedType + ) { + $dimType = $readType($var->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $types = $types->unionWith( + $this->typeSpecifier->create( + $var->var, + new HasOffsetType($dimType), + $context, + $s, + )->setRootExpr($expr), + ); + } else { + $varType = $readType($var->var); + + $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); + if ($narrowedKey !== null) { + $types = $types->unionWith( + $this->typeSpecifier->create( + $var->dim, + $narrowedKey, + $context, + $s, + )->setRootExpr($expr), + ); + } - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $types = $types->unionWith( - $this->typeSpecifier->create( - $var->var, - new HasOffsetType($dimType), - $context, - $scope, - )->setRootExpr($expr), - ); - } else { - $varType = $scope->getType($var->var); + if ($varType->isArray()->yes()) { + $types = $types->unionWith( + $this->typeSpecifier->create( + $var->var, + new NonEmptyArrayType(), + $context, + $s, + )->setRootExpr($expr), + ); + } + } + } - $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); - if ($narrowedKey !== null) { + if ( + $var instanceof PropertyFetch + && $var->name instanceof Identifier + ) { $types = $types->unionWith( - $this->typeSpecifier->create( - $var->dim, - $narrowedKey, - $context, - $scope, - )->setRootExpr($expr), + $this->typeSpecifier->create($var->var, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), ); - } - - if ($varType->isArray()->yes()) { + } elseif ( + $var instanceof StaticPropertyFetch + && $var->class instanceof Expr + && $var->name instanceof VarLikeIdentifier + ) { $types = $types->unionWith( - $this->typeSpecifier->create( - $var->var, - new NonEmptyArrayType(), - $context, - $scope, - )->setRootExpr($expr), + $this->typeSpecifier->create($var->class, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), ); } + + $types = $types->unionWith( + $this->typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($expr), + ); } - } - if ( - $var instanceof PropertyFetch - && $var->name instanceof Identifier - ) { - $types = $types->unionWith( - $this->typeSpecifier->create($var->var, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), - ); - } elseif ( - $var instanceof StaticPropertyFetch - && $var->class instanceof Expr - && $var->name instanceof VarLikeIdentifier - ) { - $types = $types->unionWith( - $this->typeSpecifier->create($var->class, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), - ); - } + return $types; + }, + ); + } - $types = $types->unionWith( - $this->typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr), - ); + /** + * @param array $chainResults + */ + private function captureChainResults(Expr $node, ExpressionResultStorage $storage, array &$chainResults): void + { + $result = $storage->findExpressionResult($node); + if ($result !== null) { + $chainResults[spl_object_id($node)] = $result; } - return $types; + if ($node instanceof ArrayDimFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + if ($node->dim !== null) { + $this->captureChainResults($node->dim, $storage, $chainResults); + } + } elseif ($node instanceof PropertyFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + } elseif ($node instanceof StaticPropertyFetch && $node->class instanceof Expr) { + $this->captureChainResults($node->class, $storage, $chainResults); + } } } From 75fdea7441e345ca3f2d2a72831d708e011b3a8d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 14:29:04 +0200 Subject: [PATCH 116/378] ClosureHandler and ArrowFunctionHandler are no longer TypeResolvingExprHandler Both compute their ClosureType eagerly in processExpr and store it as a value on the ExpressionResult. A lazy typeCallback cannot be used: getClosureType re-processes the closure body and closures are excluded from hasTrackedExpressionType(), so each getType() on the closure re-walks the body and re-asks the closure's own type before the cache is populated - unbounded re-entry. A closure whose ExpressionResult is not stored yet (its body asks its own type while a callable parameter is being derived from it), and a closure passed as a call argument (stored by NodeScopeResolver without an eager type), are resolved directly via ClosureTypeResolver in resolveTypeOfNewWorldHandlerNode - mirroring resolveCallableTypeForScope - never on demand. A self-by-ref closure is answered by getClosureType's existing depth guard. ExpressionResult gains eager ?Type type/nativeType; the accessors return them first and getTypeForScope honours a native-promoted scope. This was the last handler implementing TypeResolvingExprHandler, so the interface and its two instanceof forks (MutatingScope, TypeSpecifier) are deleted. --- .../ExprHandler/ArrowFunctionHandler.php | 34 +++++----- src/Analyser/ExprHandler/ClosureHandler.php | 34 +++++----- src/Analyser/ExprHandler/VariableHandler.php | 3 +- src/Analyser/ExpressionResult.php | 34 +++++++++- src/Analyser/ExpressionResultFactory.php | 2 + src/Analyser/ExpressionResultStorageStack.php | 5 +- src/Analyser/MutatingScope.php | 64 +++++++++---------- src/Analyser/NodeScopeResolver.php | 22 +++++-- src/Analyser/TypeResolvingExprHandler.php | 30 --------- src/Analyser/TypeSpecifier.php | 4 -- 10 files changed, 118 insertions(+), 114 deletions(-) delete mode 100644 src/Analyser/TypeResolvingExprHandler.php diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index da01eb8e39f..d550800e416 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -9,27 +9,25 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ArrowFunctionHandler implements TypeResolvingExprHandler +final class ArrowFunctionHandler implements ExprHandler { public function __construct( private ClosureTypeResolver $closureTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -43,6 +41,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $result = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + // A plain typeCallback recursing through getClosureType() would re-walk + // the body each getType() ask before the cache populates and hang; + // ExpressionResult excludes closures from its tracked-type early return. + // Compute the ClosureType once here and store it as an eager value. The + // native flavour mirrors what getNativeType() did via resolveType() on a + // promoted scope (getClosureType($scope->doNotTreatPhpDocTypesAsCertain())). + $type = $this->closureTypeResolver->getClosureType($scope, $expr); + $nativeType = $this->closureTypeResolver->getClosureType($scope->doNotTreatPhpDocTypesAsCertain(), $expr); + return $this->expressionResultFactory->create( $result->getScope(), beforeScope: $scope, @@ -51,17 +58,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + type: $type, + nativeType: $nativeType, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->closureTypeResolver->getClosureType($scope, $expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 65e801d6350..d75bbc069bd 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -9,27 +9,25 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeResolvingExprHandler; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Type; /** - * @implements TypeResolvingExprHandler + * @implements ExprHandler */ #[AutowiredService] -final class ClosureHandler implements TypeResolvingExprHandler +final class ClosureHandler implements ExprHandler { public function __construct( private ClosureTypeResolver $closureTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -43,6 +41,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); + // A plain typeCallback recursing through getClosureType() would re-walk + // the body each getType() ask before the cache populates and hang; + // ExpressionResult excludes closures from its tracked-type early return. + // Compute the ClosureType once here and store it as an eager value. The + // native flavour mirrors what getNativeType() did via resolveType() on a + // promoted scope (getClosureType($scope->doNotTreatPhpDocTypesAsCertain())). + $type = $this->closureTypeResolver->getClosureType($scope, $expr); + $nativeType = $this->closureTypeResolver->getClosureType($scope->doNotTreatPhpDocTypesAsCertain(), $expr); + return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), beforeScope: $scope, @@ -51,17 +58,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + type: $type, + nativeType: $nativeType, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->closureTypeResolver->getClosureType($scope, $expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 984640d5241..d687e5daffc 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -54,8 +54,7 @@ public function supports(Expr $expr): bool * Evaluates the variable as a read on the asking scope. Also used by * AssignHandler for the placeholder result it stores for an assignment * target - every stored result for a Variable node must carry a - * typeCallback now that this handler no longer implements - * TypeResolvingExprHandler. + * typeCallback so it can resolve its own type from the stored result. * * @return Closure(MutatingScope): Type */ diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index b5796b32cc8..ec29eb2ce0f 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -60,6 +60,8 @@ public function __construct( ?callable $typeCallback = null, ?callable $specifyTypesCallback = null, ?callable $createTypesCallback = null, + private ?Type $type = null, + private ?Type $nativeType = null, ) { $this->truthyScopeCallback = $truthyScopeCallback; @@ -178,6 +180,10 @@ public function isAlwaysTerminating(): bool public function getType(): Type { + if ($this->type !== null) { + return $this->type; + } + if ($this->cachedType !== null) { return $this->cachedType; } @@ -198,6 +204,10 @@ public function getType(): Type public function getNativeType(): Type { + if ($this->nativeType !== null) { + return $this->nativeType; + } + if ($this->cachedNativeType !== null) { return $this->cachedNativeType; } @@ -224,9 +234,14 @@ private function hasTrackedExpressionType(MutatingScope $scope): bool && $scope->hasExpressionType($this->expr)->yes(); } - public function hasTypeCallback(): bool + /** + * Whether this result can answer its own type without asking the scope - + * either an eagerly computed value (e.g. a closure's ClosureType) or a + * typeCallback. The new-world resolution in MutatingScope gates on this. + */ + public function canResolveOwnType(): bool { - return $this->typeCallback !== null; + return $this->type !== null || $this->typeCallback !== null; } /** @@ -268,6 +283,17 @@ public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSp */ public function getTypeForScope(MutatingScope $scope): Type { + // A native-promoted scope asks getType() but means the native flavour + // (MutatingScope::getNativeType() promotes then calls getType()); the + // eager value is stored as a (phpdoc, native) pair, so honour the scope. + if ($this->nativeType !== null && $scope->nativeTypesPromoted) { + return $this->nativeType; + } + + if ($this->type !== null) { + return $this->type; + } + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); } @@ -278,6 +304,10 @@ public function getTypeForScope(MutatingScope $scope): Type /** Native counterpart of getTypeForScope(). */ public function getNativeTypeForScope(MutatingScope $scope): Type { + if ($this->nativeType !== null) { + return $this->nativeType; + } + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope, $this->expr)); diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index b996c62ce30..f960181d3ae 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -32,6 +32,8 @@ public function create( ?callable $typeCallback = null, ?callable $specifyTypesCallback = null, ?callable $createTypesCallback = null, + ?Type $type = null, + ?Type $nativeType = null, ): ExpressionResult; } diff --git a/src/Analyser/ExpressionResultStorageStack.php b/src/Analyser/ExpressionResultStorageStack.php index 9d0e37f4e71..7c1ac9d4ab2 100644 --- a/src/Analyser/ExpressionResultStorageStack.php +++ b/src/Analyser/ExpressionResultStorageStack.php @@ -19,9 +19,8 @@ * NodeScopeResolver pushes a storage for the duration of an analysis (file, * statement list, trait pass, on-demand expression) through * MutatingScope::pushExpressionResultStorage() and must always pop it - * in a finally block. Old-world type questions about expressions whose - * handler no longer implements TypeResolvingExprHandler are answered from - * the current storage (see MutatingScope::resolveTypeOfNewWorldHandlerNode()). + * in a finally block. Old-world type questions about an expression are answered + * from the current storage (see MutatingScope::resolveTypeOfNewWorldHandlerNode()). * A scope used outside any running analysis simply misses here and resolves * on demand with a throwaway storage. */ diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index ca95595915d..f483763adf4 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -21,6 +21,7 @@ use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; @@ -1093,24 +1094,6 @@ private function resolveType(string $exprString, Expr $node): Type $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); if ($exprHandler !== null) { - if ($exprHandler instanceof TypeResolvingExprHandler) { - // A call handler that processed this node wires a typeCallback onto - // its stored ExpressionResult carrying the acceptor resolved from - // the arg types gathered on the arg-to-arg evolving scope. Prefer it - // over resolveType(), whose own re-selection would lose generics - // inferred from sibling args. resolveType() still answers synthetic / - // not-yet-processed nodes. - $storage = $this->expressionResultStorageStack->getCurrent(); - if ($storage !== null) { - $result = $storage->findExpressionResult($node); - if ($result !== null && $result->hasTypeCallback()) { - return $result->getTypeForScope($this->toMutatingScope()); - } - } - - return $exprHandler->resolveType($this, $node); - } - return $this->resolveTypeOfNewWorldHandlerNode($node); } @@ -1118,10 +1101,10 @@ private function resolveType(string $exprString, Expr $node): Type } /** - * The handler of the node no longer implements TypeResolvingExprHandler. + * Resolves the type of a node whose ExprHandler produced an ExpressionResult. * The answer comes from the ExpressionResult stored during the analysis - * currently in progress, or from processing the node on demand (synthetic - * nodes, or no analysis in progress at all). + * currently in progress (its eager type or typeCallback), or from processing + * the node on demand (synthetic nodes, or no analysis in progress at all). * * The scope deliberately does not reference the storage - that would create * a reference cycle that never gets collected (see ExpressionResultStorageStack). @@ -1136,18 +1119,30 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage !== null) { $result = $storage->findExpressionResult($node); - if ($result !== null) { - if (!$result->hasTypeCallback()) { - throw new ShouldNotHappenException(sprintf( - 'ExprHandler for %s does not implement TypeResolvingExprHandler but its ExpressionResult is missing a typeCallback.', - get_class($node), - )); - } - - return $result->getTypeForScope($scope); + if ($result !== null && $result->canResolveOwnType()) { + return $scope->nativeTypesPromoted ? $result->getNativeTypeForScope($scope) : $result->getTypeForScope($scope); } } + // A closure/arrow function type is computed directly (as + // resolveCallableTypeForScope() also does) - never by processing it on + // demand, which would re-enter ClosureHandler::processExpr() endlessly. + // This answers both a closure whose result is not stored yet (its own + // body walk asks for its type, and a callable parameter is derived from + // it while it is being processed) and a closure passed as a call argument, + // whose result NodeScopeResolver stores without an eager type. + // getClosureType()'s own depth guard answers the self-by-ref ask. + if ($node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) { + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $node); + } + + if ($storage !== null && $storage->findExpressionResult($node) !== null) { + throw new ShouldNotHappenException(sprintf( + 'ExpressionResult of %s cannot resolve its own type (no eager type, no typeCallback).', + get_class($node), + )); + } + // a synthetic node, or no analysis in progress $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, @@ -1155,7 +1150,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), ); - return $onDemandResult->getTypeForScope($scope); + return $scope->nativeTypesPromoted ? $onDemandResult->getNativeTypeForScope($scope) : $onDemandResult->getTypeForScope($scope); } /** @@ -1208,10 +1203,9 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array /** * Narrowing counterpart of resolveTypeOfNewWorldHandlerNode() - the old-world - * TypeSpecifier dispatcher asks here for nodes whose handler no longer - * implements TypeResolvingExprHandler. Returns null when the ExpressionResult - * carries no specifyTypesCallback - the dispatcher falls back to default - * truthy/falsey narrowing, which is what such handlers used to implement. + * TypeSpecifier dispatcher asks here for a node's narrowing. Returns null when + * the ExpressionResult carries no specifyTypesCallback - the dispatcher falls + * back to default truthy/falsey narrowing. * * @internal */ diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c4724e3ae54..1ef8326ffe8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -428,8 +428,8 @@ public function storeExpressionResult(ExpressionResultStorage $storage, Expr $ex if (self::$guardNewWorld) { self::$guardProcessedExprIds[spl_object_id($expr)] = true; } - // converted handlers (no TypeResolvingExprHandler) are answered from - // stored results in both worlds - storing must not depend on fibers + // handlers are answered from stored results in both worlds - storing must + // not depend on fibers $storage->storeExpressionResult($expr, $expressionResult); } @@ -3483,7 +3483,7 @@ public function createNativeCallableParameters(MutatingScope $scope, Expr $closu * Resolves the type of an expression a callable parameter is derived from - * either the closure/arrow function whose acceptors describe the parameters, * or a call argument refining them. A closure/arrow function is resolved - * through its TypeResolvingExprHandler (as Scope::getType() would), not by + * directly through ClosureTypeResolver (as Scope::getType() would), not by * processing it on demand: createCallableParameters() runs while that very * closure is being processed, so on-demand processing would re-enter * processClosureNodeInternal() endlessly. @@ -4033,6 +4033,7 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); } + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( $closureResult->getScope(), $scopeToPass, @@ -4041,6 +4042,8 @@ public function processArgs( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), )); $uses = []; @@ -4112,7 +4115,18 @@ public function processArgs( $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; } } - $this->storeExpressionResult($storage, $arg->value, $arrowFunctionResult); + $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( + $arrowFunctionResult->getScope(), + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: $arrowFunctionResult->hasYield(), + isAlwaysTerminating: $arrowFunctionResult->isAlwaysTerminating(), + throwPoints: $arrowFunctionResult->getThrowPoints(), + impurePoints: $arrowFunctionResult->getImpurePoints(), + type: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + )); } else { $exprType = $this->readStoredOrPriceOnDemand($arg->value, $scope); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; diff --git a/src/Analyser/TypeResolvingExprHandler.php b/src/Analyser/TypeResolvingExprHandler.php deleted file mode 100644 index 030a2dfb8a3..00000000000 --- a/src/Analyser/TypeResolvingExprHandler.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -interface TypeResolvingExprHandler extends ExprHandler -{ - - /** - * @param T $expr - */ - public function resolveType(MutatingScope $scope, Expr $expr): Type; - - /** - * @param T $expr - */ - public function specifyTypes( - TypeSpecifier $typeSpecifier, - Scope $scope, - Expr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes; - -} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 236afa02310..d57f3792803 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -91,10 +91,6 @@ public function specifyTypesInCondition( $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { - if ($exprHandler instanceof TypeResolvingExprHandler) { - return $exprHandler->specifyTypes($this, $scope, $expr, $context); - } - if ($scope instanceof MutatingScope) { $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); if ($specifiedTypes !== null) { From 87bb69376f31b7167962df49c8326cde04ffd969 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 16:20:16 +0200 Subject: [PATCH 117/378] Build the closure and arrow function type from the single body walk processClosureNode()/processArrowFunctionNode() already walk the closure body once and gather its return and yield statements. The ClosureType is now built from those gathered results instead of getClosureType() walking the body a second time. The native flavour is produced from the same single walk too: - a closure carries no @param/@return of its own and its native type resolves the body the same way its phpdoc type does, so it reuses the phpdoc ClosureType; - an arrow function's native return type is its body expression's native type, read off the same arrowScope the single walk produced. getClosureType() is retained for the paths that have no prior walk to reuse: array_map()/immediately-invoked callbacks (whose parameter types come from the array element type / invocation arguments), and a closure asking its own type mid-walk (enterAnonymousFunction()), bounded by its depth guard. --- .../ExprHandler/ArrowFunctionHandler.php | 32 +- src/Analyser/ExprHandler/ClosureHandler.php | 26 +- .../Helper/ClosureTypeResolver.php | 755 ++++++++++++------ src/Analyser/NodeScopeResolver.php | 113 ++- src/Analyser/ProcessArrowFunctionResult.php | 59 ++ src/Analyser/ProcessClosureResult.php | 48 ++ 6 files changed, 747 insertions(+), 286 deletions(-) create mode 100644 src/Analyser/ProcessArrowFunctionResult.php diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index d550800e416..5293eb4ef45 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -39,16 +39,36 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $result = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $result = $arrowFunctionResult->getExpressionResult(); // A plain typeCallback recursing through getClosureType() would re-walk // the body each getType() ask before the cache populates and hang; // ExpressionResult excludes closures from its tracked-type early return. - // Compute the ClosureType once here and store it as an eager value. The - // native flavour mirrors what getNativeType() did via resolveType() on a - // promoted scope (getClosureType($scope->doNotTreatPhpDocTypesAsCertain())). - $type = $this->closureTypeResolver->getClosureType($scope, $expr); - $nativeType = $this->closureTypeResolver->getClosureType($scope->doNotTreatPhpDocTypesAsCertain(), $expr); + // Compute the ClosureType once here and store it as an eager value. + // + // Both flavours are built from the arrow function body the single walk in + // processArrowFunctionNode() already covered, without a second walk: the + // native flavour reads the body expression's stored native types off the + // same arrowScope (an arrow's native return type is its body's native type). + $arrowScope = $arrowFunctionResult->getArrowFunctionScope(); + $type = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); + $nativeType = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + native: true, + ); return $this->expressionResultFactory->create( $result->getScope(), diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index d75bbc069bd..4aceca3957f 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -44,11 +44,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A plain typeCallback recursing through getClosureType() would re-walk // the body each getType() ask before the cache populates and hang; // ExpressionResult excludes closures from its tracked-type early return. - // Compute the ClosureType once here and store it as an eager value. The - // native flavour mirrors what getNativeType() did via resolveType() on a - // promoted scope (getClosureType($scope->doNotTreatPhpDocTypesAsCertain())). - $type = $this->closureTypeResolver->getClosureType($scope, $expr); - $nativeType = $this->closureTypeResolver->getClosureType($scope->doNotTreatPhpDocTypesAsCertain(), $expr); + // Compute the ClosureType once here and store it as an eager value. + // + // The phpdoc flavour is built from the returns/yields the single body walk + // in processClosureNode() already gathered, without a second walk. + // + // A closure carries no @param/@return of its own, and its native type + // resolves the body the same way its phpdoc type does (a closure's native + // type equals its phpdoc type - e.g. a closure returning a positive-int + // method is Closure(): int<1, max> in both flavours). So the native + // flavour reuses the phpdoc ClosureType - no native walk. + $type = $this->closureTypeResolver->buildClosureTypeForClosure( + $scope, + $expr, + $processClosureResult->getGatheredReturnStatements(), + $processClosureResult->getGatheredYieldStatements(), + $processClosureResult->getExecutionEnds(), + $processClosureResult->getThrowPoints(), + $processClosureResult->getClosureTypeImpurePoints(), + $processClosureResult->getInvalidateExpressions(), + ); + $nativeType = $type; return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ff165386f75..3ba94babe54 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -26,6 +27,7 @@ use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; @@ -38,6 +40,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; use function array_key_exists; @@ -58,110 +61,29 @@ public function __construct( { } + /** + * Resolves a closure/arrow function type by walking its body itself. Used by + * the paths that have NO prior walk to read return/yield types from - a + * closure asking its own type before its result is stored, and + * resolveCallableTypeForScope(). A self-by-ref closure legitimately re-walks + * here (the $resolveClosureTypeDepth guard answers that ask). + * + * Callers that have already walked the body (the closure/arrow handlers and + * the closure-as-call-arg store sites) feed the gathered returns/yields to + * buildClosureType() instead, which constructs the same ClosureType without + * a second walk. + */ public function getClosureType( MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ): ClosureType { - $parameters = []; - $isVariadic = false; - $firstOptionalParameterIndex = null; - foreach ($expr->params as $i => $param) { - $isOptionalCandidate = $param->default !== null || $param->variadic; - - if ($isOptionalCandidate) { - if ($firstOptionalParameterIndex === null) { - $firstOptionalParameterIndex = $i; - } - } else { - $firstOptionalParameterIndex = null; - } - } - - foreach ($expr->params as $i => $param) { - if ($param->variadic) { - $isVariadic = true; - } - if (!$param->var instanceof Variable || !is_string($param->var->name)) { - throw new ShouldNotHappenException(); - } - $parameters[] = new NativeParameterReflection( - $param->var->name, - $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, - $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), - $param->byRef - ? PassedByReference::createCreatesNewVariable() - : PassedByReference::createNo(), - $param->variadic, - $param->default !== null ? $scope->getType($param->default) : null, - ); - } - - $callableParameters = null; - $nativeCallableParameters = null; - $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); - $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); - if ($arrayMapArgs !== null) { - $callableParameters = []; - $nativeCallableParameters = []; - foreach ($arrayMapArgs as $funcCallArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } elseif ($immediatelyInvokedArgs !== null) { - foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } else { - $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { - [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; - if ($inParameter !== null) { - $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); - $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); - $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); - } - } - } + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { - $yieldNode = $expr->expr; - - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyType = new IntegerType(); - } else { - $keyType = $arrowScope->getType($yieldNode->key); - } - - if ($yieldNode->value === null) { - $valueType = new NullType(); - } else { - $valueType = $arrowScope->getType($yieldNode->value); - } - } else { - $yieldFromType = $arrowScope->getType($yieldNode->expr); - $keyType = $arrowScope->getIterableKeyType($yieldFromType); - $valueType = $arrowScope->getIterableValueType($yieldFromType); - } - - $returnType = new GenericObjectType(Generator::class, [ - $keyType, - $valueType, - new MixedType(), - new VoidType(), - ]); - } else { - $returnType = $arrowScope->getKeepVoidType($expr->expr); - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); - } - } + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); $arrowFunctionImpurePoints = []; $invalidateExpressions = []; @@ -197,214 +119,527 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu ); $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $usedVariables = []; - } else { - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey = $scope->getClosureScopeCacheKey(); - if (array_key_exists($cacheKey, $cachedTypes)) { - $cachedClosureData = $cachedTypes[$cacheKey]; - - $mustUseReturnValue = TrinaryLogic::createNo(); - foreach ($expr->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toLowerString() === 'nodiscard') { - $mustUseReturnValue = TrinaryLogic::createYes(); - break; - } - } + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); + } + + $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cacheKey = $scope->getClosureScopeCacheKey(); + if (array_key_exists($cacheKey, $cachedTypes)) { + return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); + } + if (self::$resolveClosureTypeDepth >= 2) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + + self::$resolveClosureTypeDepth++; + + $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); + $closureReturnStatements = []; + $closureYieldStatements = []; + $closureExecutionEnds = []; + $closureImpurePoints = []; + $invalidateExpressions = []; + + try { + $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$closureExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; } - return new ClosureType( - $parameters, - $cachedClosureData['returnType'], - $isVariadic, - TemplateTypeMap::createEmpty(), - TemplateTypeMap::createEmpty(), - TemplateTypeVarianceMap::createEmpty(), - throwPoints: $cachedClosureData['throwPoints'], - impurePoints: $cachedClosureData['impurePoints'], - invalidateExpressions: $cachedClosureData['invalidateExpressions'], - usedVariables: $cachedClosureData['usedVariables'], - acceptsNamedArguments: TrinaryLogic::createYes(), - mustUseReturnValue: $mustUseReturnValue, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); - } - if (self::$resolveClosureTypeDepth >= 2) { - return new ClosureType( - $parameters, - $scope->getFunctionType($expr->returnType, false, false), - $isVariadic, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); - } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - self::$resolveClosureTypeDepth++; + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + return; + } - $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $closureReturnStatements = []; - $closureYieldStatements = []; - $onlyNeverExecutionEnds = null; - $closureImpurePoints = []; - $invalidateExpressions = []; + if ($node instanceof ExecutionEndNode) { + $closureExecutionEnds[] = $node; + return; + } - try { - $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { + return; + } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } - if ($node instanceof ExecutionEndNode) { - if ($node->getStatementResult()->isAlwaysTerminating()) { - foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { - if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { - $onlyNeverExecutionEnds = false; - continue; - } - - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - - break; - } - - if (count($node->getStatementResult()->getExitPoints()) === 0) { - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - } - } else { - $onlyNeverExecutionEnds = false; - } + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); - return; - } + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $closureReturnStatements, + $closureYieldStatements, + $closureExecutionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + ); + } - if ($node instanceof Node\Stmt\Return_) { - $closureReturnStatements[] = [$node, $scope]; - } + /** + * Constructs a closure type from data the engine already gathered while + * walking the body once (see NodeScopeResolver::processClosureNode()), + * without a second walk. The return/yield expression types are read from + * their stored results. + * + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param InternalThrowPoint[] $throwPoints the single body walk's internal throw points + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + statement result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForClosure( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($scope, $expr); + } - if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { - return; - } + [$parameters, $isVariadic] = $this->buildParametersAndAcceptors($scope, $expr); + + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $returnStatements, + $yieldStatements, + $executionEnds, + array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $throwPoints), + $impurePoints, + $invalidateExpressions, + ); + } + + /** + * Constructs an arrow function type from data the engine already gathered + * while walking the body once (see NodeScopeResolver:: + * processArrowFunctionNode()), without a second walk. The return/yield + * expression types are read from their stored results on $arrowScope. + * + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + expression result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForArrowFunction( + MutatingScope $scope, + ArrowFunction $expr, + MutatingScope $arrowScope, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + bool $native = false, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); + } + + [$parameters, $isVariadic] = $this->buildParametersAndAcceptors($scope, $expr); + + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); + } + + /** + * Whether getClosureType() would walk the body with different parameter types + * than NodeScopeResolver's single walk (processClosureNode()/ + * processArrowFunctionNode()) did. array_map() callbacks and immediately + * invoked closures get their parameter types from the array element type / + * the invocation arguments in getClosureType(), whereas the single walk types + * them from the closure's passed-to callable type - so the return type read + * from the gathered scopes would differ, and getClosureType() must re-walk. + */ + private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool + { + return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null + || $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME) !== null; + } + + /** + * @param list $parameters + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + */ + private function buildClosureTypeFromClosureWalk( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $parameters, + bool $isVariadic, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ): ClosureType + { + $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); + + $returnTypes = []; + $hasNull = false; + foreach ($returnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } + + $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + } - $closureYieldStatements[] = [$node, $scope]; - }, StatementContext::createTopLevel()); - } finally { - self::$resolveClosureTypeDepth--; + if (count($returnTypes) === 0) { + if ($onlyNeverExecutionEnds === true && !$hasNull) { + $returnType = new NonAcceptingNeverType(); + } else { + $returnType = new VoidType(); + } + } else { + if ($onlyNeverExecutionEnds === true) { + $returnTypes[] = new NonAcceptingNeverType(); + } + if ($hasNull) { + $returnTypes[] = new NullType(); } + $returnType = TypeCombinator::union(...$returnTypes); + } - $throwPoints = $closureStatementResult->getThrowPoints(); - $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + if (count($yieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($yieldStatements as [$yieldNode, $yieldScope]) { + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); + } + + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); + } - $returnTypes = []; - $hasNull = false; - foreach ($closureReturnStatements as [$returnNode, $returnScope]) { - if ($returnNode->expr === null) { - $hasNull = true; continue; } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); + $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); + $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + } + + $returnType = new GenericObjectType(Generator::class, [ + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes), + new MixedType(), + $returnType, + ]); + } else { + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); } + } + + $usedVariables = []; + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $usedVariables[] = $use->var->name; + } + + foreach ($expr->uses as $use) { + if (!$use->byRef) { + continue; + } + + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'functionCall', + 'call to a Closure with by-ref use', + true, + ); + break; + } + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables); + } - if (count($returnTypes) === 0) { - if ($onlyNeverExecutionEnds === true && !$hasNull) { - $returnType = new NonAcceptingNeverType(); + private function resolveArrowFunctionReturnType( + MutatingScope $scope, + MutatingScope $arrowScope, + ArrowFunction $expr, + bool $native = false, + ): Type + { + // Unlike a closure (whose native type equals its phpdoc type), an arrow + // function's native return type is the body expression's native type + // (e.g. fn () => methodReturningPositiveInt() is Closure(): int natively, + // Closure(): int<1, max> in phpdoc). The body was already processed in the + // single walk, so the native flavour just reads the stored native types off + // the same arrowScope - no second walk. + $readScope = $native ? $arrowScope->doNotTreatPhpDocTypesAsCertain() : $arrowScope; + + if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { + $yieldNode = $expr->expr; + + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyType = new IntegerType(); } else { - $returnType = new VoidType(); - } - } else { - if ($onlyNeverExecutionEnds === true) { - $returnTypes[] = new NonAcceptingNeverType(); + $keyType = $readScope->getType($yieldNode->key); } - if ($hasNull) { - $returnTypes[] = new NullType(); + + if ($yieldNode->value === null) { + $valueType = new NullType(); + } else { + $valueType = $readScope->getType($yieldNode->value); } - $returnType = TypeCombinator::union(...$returnTypes); + } else { + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyType = $readScope->getIterableKeyType($yieldFromType); + $valueType = $readScope->getIterableValueType($yieldFromType); } - if (count($closureYieldStatements) > 0) { - $keyTypes = []; - $valueTypes = []; - foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyTypes[] = new IntegerType(); - } else { - $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); - } - - if ($yieldNode->value === null) { - $valueTypes[] = new NullType(); - } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); - } + return new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); + } + $returnType = $readScope->getKeepVoidType($expr->expr); + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } + + return $returnType; + } + + /** + * Whether every execution end of the closure body is a "never" terminator + * (throw/exit) rather than a return: null when there were no execution ends, + * false once a return (or non-terminating end) is seen. + * + * @param list $executionEnds + */ + private function deriveOnlyNeverExecutionEnds(array $executionEnds): ?bool + { + $onlyNeverExecutionEnds = null; + foreach ($executionEnds as $node) { + if ($node->getStatementResult()->isAlwaysTerminating()) { + foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { + if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { + $onlyNeverExecutionEnds = false; continue; } - $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); - $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); - $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } + + break; } - $returnType = new GenericObjectType(Generator::class, [ - TypeCombinator::union(...$keyTypes), - TypeCombinator::union(...$valueTypes), - new MixedType(), - $returnType, - ]); - } else { - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + if (count($node->getStatementResult()->getExitPoints()) === 0) { + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } } + } else { + $onlyNeverExecutionEnds = false; } + } - $usedVariables = []; - foreach ($expr->uses as $use) { - if (!is_string($use->var->name)) { - continue; + return $onlyNeverExecutionEnds; + } + + /** + * Builds the closure/arrow function's declared parameters (independent of the + * body walk) and the callable parameter acceptors derived from the call site. + * + * @return array{list, bool, ParameterReflection[]|null, ParameterReflection[]|null} + */ + private function buildParametersAndAcceptors( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + ): array + { + $parameters = []; + $isVariadic = false; + $firstOptionalParameterIndex = null; + foreach ($expr->params as $i => $param) { + $isOptionalCandidate = $param->default !== null || $param->variadic; + + if ($isOptionalCandidate) { + if ($firstOptionalParameterIndex === null) { + $firstOptionalParameterIndex = $i; } + } else { + $firstOptionalParameterIndex = null; + } + } - $usedVariables[] = $use->var->name; + foreach ($expr->params as $i => $param) { + if ($param->variadic) { + $isVariadic = true; } + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $parameters[] = new NativeParameterReflection( + $param->var->name, + $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, + $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), + $param->byRef + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $param->variadic, + $param->default !== null ? $scope->getType($param->default) : null, + ); + } - foreach ($expr->uses as $use) { - if (!$use->byRef) { - continue; + $callableParameters = null; + $nativeCallableParameters = null; + $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + if ($arrayMapArgs !== null) { + $callableParameters = []; + $nativeCallableParameters = []; + foreach ($arrayMapArgs as $funcCallArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } elseif ($immediatelyInvokedArgs !== null) { + foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } else { + $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); + $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); + $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); } + } + } - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'functionCall', - 'call to a Closure with by-ref use', - true, - ); - break; + return [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters]; + } + + /** + * @param list $parameters + * @param array{returnType: Type, throwPoints: SimpleThrowPoint[], impurePoints: SimpleImpurePoint[], invalidateExpressions: InvalidateExprNode[], usedVariables: string[]} $cachedClosureData + */ + private function createClosureTypeFromCache( + Node\Expr\Closure $expr, + array $parameters, + bool $isVariadic, + array $cachedClosureData, + ): ClosureType + { + $mustUseReturnValue = TrinaryLogic::createNo(); + foreach ($expr->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toLowerString() === 'nodiscard') { + $mustUseReturnValue = TrinaryLogic::createYes(); + break; + } } } + return new ClosureType( + $parameters, + $cachedClosureData['returnType'], + $isVariadic, + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + throwPoints: $cachedClosureData['throwPoints'], + impurePoints: $cachedClosureData['impurePoints'], + invalidateExpressions: $cachedClosureData['invalidateExpressions'], + usedVariables: $cachedClosureData['usedVariables'], + acceptsNamedArguments: TrinaryLogic::createYes(), + mustUseReturnValue: $mustUseReturnValue, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + + /** + * Constructs the final ClosureType from the resolved return type and the + * gathered throw/impure/invalidate points. Adds the by-ref-parameter impure + * point, populates the per-scope phpdoc-type cache (closures only), and + * resolves the #[NoDiscard] attribute. + * + * @param list $parameters + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + * @param string[] $usedVariables + */ + private function assembleClosureType( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + array $parameters, + bool $isVariadic, + Type $returnType, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + array $usedVariables, + ): ClosureType + { foreach ($parameters as $parameter) { if ($parameter->passedByReference()->no()) { continue; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1ef8326ffe8..d0e25f598d8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3301,10 +3301,12 @@ private function processClosureNodeInternal( $executionEnds = []; $gatheredReturnStatements = []; + $gatheredReturnStatementsWithScope = []; $gatheredYieldStatements = []; + $gatheredYieldStatementsWithScope = []; $closureImpurePoints = []; $invalidateExpressions = []; - $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { + $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { $nodeCallback($node, $scope); if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { return; @@ -3330,12 +3332,14 @@ private function processClosureNodeInternal( } if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { $gatheredYieldStatements[] = $node; + $gatheredYieldStatementsWithScope[] = [$node, $scope]; } if (!$node instanceof Return_) { return; } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $gatheredReturnStatementsWithScope[] = [$node, $scope]; }; if (count($byRefUses) === 0) { @@ -3350,7 +3354,16 @@ private function processClosureNodeInternal( array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureScope, $storage); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions); + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + ); } $originalStorage = $storage; @@ -3400,7 +3413,18 @@ private function processClosureNodeInternal( array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureScope, $storage); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $closureResultScope, $byRefUses); + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + $closureResultScope, + $byRefUses, + ); } /** @@ -3438,7 +3462,7 @@ public function processArrowFunctionNode( callable $nodeCallback, ?Type $passedToType, ?Type $nativePassedToType = null, - ): ExpressionResult + ): ProcessArrowFunctionResult { foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -3456,9 +3480,50 @@ public function processArrowFunctionNode( throw new ShouldNotHappenException(); } $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); - $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); + // Gather the property-assign impure points and invalidate expressions the + // arrow function type needs (mirroring ClosureTypeResolver::getClosureType()), + // on top of the regular rule node callback, so the single body walk here + // feeds ClosureTypeResolver::buildClosureTypeForArrowFunction(). + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + $arrowFunctionStmtsCallback = static function (Node $node, Scope $innerScope) use ($nodeCallback, $arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + $nodeCallback($node, $innerScope); + if ($innerScope->getAnonymousFunctionReflection() !== $arrowFunctionScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if (!$node instanceof PropertyAssignNode) { + return; + } + + $arrowFunctionImpurePoints[] = new ImpurePoint( + $innerScope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + }; + + $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $arrowFunctionStmtsCallback, ExpressionContext::createTopLevel()); + + $closureTypeThrowPoints = array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $exprResult->getThrowPoints()); + $closureTypeImpurePoints = array_merge($arrowFunctionImpurePoints, $exprResult->getImpurePoints()); + + return new ProcessArrowFunctionResult( + $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()), + $arrowFunctionScope, + $closureTypeThrowPoints, + $closureTypeImpurePoints, + $invalidateExpressions, + ); } /** @@ -4042,7 +4107,16 @@ public function processArgs( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + type: $closureTypeResolver->buildClosureTypeForClosure( + $scopeToPass, + $arg->value, + $closureResult->getGatheredReturnStatements(), + $closureResult->getGatheredYieldStatements(), + $closureResult->getExecutionEnds(), + $closureResult->getThrowPoints(), + $closureResult->getClosureTypeImpurePoints(), + $closureResult->getInvalidateExpressions(), + ), nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), )); @@ -4105,9 +4179,10 @@ public function processArgs( $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null, $parameterNativeType); + $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { - $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); + $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); } if ($this->shouldInvalidateCallbackExpressions($parameter)) { $arrowFunctionType = $scope->getType($arg->value); @@ -4116,15 +4191,23 @@ public function processArgs( } } $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $arrowFunctionScope = $arrowFunctionResult->getArrowFunctionScope(); $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( - $arrowFunctionResult->getScope(), + $arrowFunctionExprResult->getScope(), beforeScope: $scopeToPass, expr: $arg->value, - hasYield: $arrowFunctionResult->hasYield(), - isAlwaysTerminating: $arrowFunctionResult->isAlwaysTerminating(), - throwPoints: $arrowFunctionResult->getThrowPoints(), - impurePoints: $arrowFunctionResult->getImpurePoints(), - type: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass, $arg->value), + hasYield: $arrowFunctionExprResult->hasYield(), + isAlwaysTerminating: $arrowFunctionExprResult->isAlwaysTerminating(), + throwPoints: $arrowFunctionExprResult->getThrowPoints(), + impurePoints: $arrowFunctionExprResult->getImpurePoints(), + type: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ), nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), )); } else { diff --git a/src/Analyser/ProcessArrowFunctionResult.php b/src/Analyser/ProcessArrowFunctionResult.php new file mode 100644 index 00000000000..a571ced99ff --- /dev/null +++ b/src/Analyser/ProcessArrowFunctionResult.php @@ -0,0 +1,59 @@ +expressionResult; + } + + public function getArrowFunctionScope(): MutatingScope + { + return $this->arrowFunctionScope; + } + + /** + * @return ThrowPoint[] + */ + public function getClosureTypeThrowPoints(): array + { + return $this->closureTypeThrowPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + + /** + * @return InvalidateExprNode[] + */ + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + +} diff --git a/src/Analyser/ProcessClosureResult.php b/src/Analyser/ProcessClosureResult.php index cb4b609e1f4..e114ac55b81 100644 --- a/src/Analyser/ProcessClosureResult.php +++ b/src/Analyser/ProcessClosureResult.php @@ -3,6 +3,10 @@ namespace PHPStan\Analyser; use PhpParser\Node\ClosureUse; +use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Expr\YieldFrom; +use PhpParser\Node\Stmt\Return_; +use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\InvalidateExprNode; final class ProcessClosureResult @@ -12,6 +16,10 @@ final class ProcessClosureResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param InvalidateExprNode[] $invalidateExpressions + * @param list $gatheredReturnStatements + * @param list $gatheredYieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $closureTypeImpurePoints already merged in getClosureType() order (property-assign impure points first, then statement result impure points) * @param ClosureUse[] $byRefUses */ public function __construct( @@ -19,6 +27,10 @@ public function __construct( private array $throwPoints, private array $impurePoints, private array $invalidateExpressions, + private array $gatheredReturnStatements, + private array $gatheredYieldStatements, + private array $executionEnds, + private array $closureTypeImpurePoints, private ?MutatingScope $byRefClosureResultScope = null, private array $byRefUses = [], ) @@ -63,4 +75,40 @@ public function getInvalidateExpressions(): array return $this->invalidateExpressions; } + /** + * @return list + */ + public function getGatheredReturnStatements(): array + { + return $this->gatheredReturnStatements; + } + + /** + * @return list + */ + public function getGatheredYieldStatements(): array + { + return $this->gatheredYieldStatements; + } + + /** + * @return list + */ + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + /** + * The closure body's impure points already merged in getClosureType() order + * (property-assign impure points first, then statement result impure points), + * ready to feed ClosureTypeResolver::buildClosureTypeForClosure(). + * + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + } From 7af9c8141112e6ab95ba1bc6d89bc8bfb4894c7e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 17:24:37 +0200 Subject: [PATCH 118/378] Enter closure and arrow function scopes without re-walking the body Entering a closure/arrow scope used to resolve the closure's full type just to store the scope's own reflection - a second body walk on top of the single walk that already produces the eager ClosureType. enterAnonymousFunction() and enterArrowFunction() now build a shallow reflection (parameters + declared return type, no walk) instead. The body-inferred ("refined") return type is still needed by the return-type rules, so it is rebuilt from the single walk's already-gathered return statements (no extra walk) and swapped onto the scope that ClosureReturnStatementsNode / InArrowFunctionNode fire with - the rules keep their precise expected return types (e.g. Bar&Foo, array{}). Closure::getCurrent() reads the scope reflection mid-body, before the refined return type exists, so it now reports the declared return type. --- .../Helper/ClosureTypeResolver.php | 17 +++++ src/Analyser/MutatingScope.php | 40 ++++++++--- src/Analyser/NodeScopeResolver.php | 67 +++++++++++++++++-- .../Analyser/nsrt/closure-get-current.php | 5 +- 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 3ba94babe54..0adfcd9ec46 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -76,10 +76,27 @@ public function __construct( public function getClosureType( MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, + bool $shallow = false, ): ClosureType { [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + // A shallow reflection is the closure/arrow function's signature without + // walking its body: parameters plus the DECLARED return type. Used at scope + // ENTRY (enterAnonymousFunction()/enterArrowFunction()) so entering a + // closure/arrow scope never re-walks the body - the refined return type is + // built afterwards from the single body walk's gathered returns and carried + // on the node/rule scope (see NodeScopeResolver::processClosureNodeInternal() + // and processArrowFunctionNode()). + if ($shallow) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f483763adf4..3b7b5403af0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -954,6 +954,36 @@ public function getAnonymousFunctionReturnType(): ?Type return $this->anonymousFunctionReflection->getReturnType(); } + /** + * Returns a scope identical to this one but with the anonymous function + * reflection replaced. The scope entered at a closure/arrow carries only a + * shallow reflection (parameters + declared return); once the single body + * walk has gathered the returns, the engine builds the refined ClosureType and + * swaps it in here so the closure/arrow return-type node and its rules see the + * refined expected return. + */ + public function withAnonymousFunctionReflection(ClosureType $anonymousFunctionReflection): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** @api */ public function getType(Expr $node): Type { @@ -2091,10 +2121,7 @@ public function enterAnonymousFunction( ?array $nativeCallableParameters = null, ): self { - $anonymousFunctionReflection = $this->resolveType('__phpstanClosure', $closure); - if (!$anonymousFunctionReflection instanceof ClosureType) { - throw new ShouldNotHappenException(); - } + $anonymousFunctionReflection = $this->container->getByType(ClosureTypeResolver::class)->getClosureType($this, $closure, shallow: true); $scope = $this->enterAnonymousFunctionWithoutReflection($closure, $callableParameters, $nativeCallableParameters); @@ -2314,10 +2341,7 @@ private function invalidateStaticExpressions(array $expressionTypes): array */ public function enterArrowFunction(Expr\ArrowFunction $arrowFunction, ?array $callableParameters, ?array $nativeCallableParameters = null): self { - $anonymousFunctionReflection = $this->resolveType('__phpStanArrowFn', $arrowFunction); - if (!$anonymousFunctionReflection instanceof ClosureType) { - throw new ShouldNotHappenException(); - } + $anonymousFunctionReflection = $this->container->getByType(ClosureTypeResolver::class)->getClosureType($this, $arrowFunction, shallow: true); $scope = $this->enterArrowFunctionWithoutReflection($arrowFunction, $callableParameters, $nativeCallableParameters); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d0e25f598d8..c499fda9732 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3345,6 +3345,7 @@ private function processClosureNodeInternal( if (count($byRefUses) === 0) { $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); + $closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, $gatheredReturnStatements, @@ -3352,7 +3353,7 @@ private function processClosureNodeInternal( $publicStatementResult, $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), - ), $closureScope, $storage); + ), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( $scope, @@ -3404,6 +3405,7 @@ private function processClosureNodeInternal( $storage = $originalStorage; $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); + $closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, $gatheredReturnStatements, @@ -3411,7 +3413,7 @@ private function processClosureNodeInternal( $publicStatementResult, $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), - ), $closureScope, $storage); + ), $closureReturnStatementsNodeScope, $storage); return new ProcessClosureResult( $scope, @@ -3427,6 +3429,47 @@ private function processClosureNodeInternal( ); } + /** + * The closure scope was entered with a shallow reflection (parameters + + * declared return, no body walk - see ClosureTypeResolver::getClosureType() + * with $shallow). Now that the single body walk has gathered the returns, + * build the refined ClosureType from them (no second walk) and swap it onto + * the scope the ClosureReturnStatementsNode fires with, so the return-type + * rules see the refined expected return (e.g. Bar&Foo, not just Foo). + * + * @param list $gatheredReturnStatementsWithScope + * @param list $gatheredYieldStatementsWithScope + * @param list $executionEnds + * @param InternalThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + */ + private function refineClosureNodeScope( + MutatingScope $closureScope, + MutatingScope $scope, + Expr\Closure $expr, + array $gatheredReturnStatementsWithScope, + array $gatheredYieldStatementsWithScope, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ): MutatingScope + { + $refinedClosureType = $this->container->getByType(ClosureTypeResolver::class)->buildClosureTypeForClosure( + $scope, + $expr, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + ); + + return $closureScope->withAnonymousFunctionReflection($refinedClosureType); + } + /** * @param InvalidateExprNode[] $invalidatedExpressions * @param string[] $uses @@ -3475,11 +3518,9 @@ public function processArrowFunctionNode( $callableParameters = $this->createCallableParameters($scope, $expr, $arrowFunctionCallArgs, $passedToType); $nativeCallableParameters = $this->createNativeCallableParameters($scope, $expr, $arrowFunctionCallArgs, $nativePassedToType); $arrowFunctionScope = $scope->enterArrowFunction($expr, $callableParameters, $nativeCallableParameters); - $arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection(); - if ($arrowFunctionType === null) { + if ($arrowFunctionScope->getAnonymousFunctionReflection() === null) { throw new ShouldNotHappenException(); } - $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); // Gather the property-assign impure points and invalidate expressions the // arrow function type needs (mirroring ClosureTypeResolver::getClosureType()), @@ -3517,6 +3558,22 @@ public function processArrowFunctionNode( $closureTypeThrowPoints = array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $exprResult->getThrowPoints()); $closureTypeImpurePoints = array_merge($arrowFunctionImpurePoints, $exprResult->getImpurePoints()); + // The arrow scope was entered with a shallow reflection (parameters + + // declared return, no body walk). Now that the single body walk above has + // run, build the refined arrow function type from the body expression's + // stored type (no second walk) and fire InArrowFunctionNode with it, so the + // node and the return-type rules see the refined expected return. + $refinedArrowFunctionType = $this->container->getByType(ClosureTypeResolver::class)->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowFunctionScope, + $closureTypeThrowPoints, + $closureTypeImpurePoints, + $invalidateExpressions, + ); + $refinedArrowFunctionScope = $arrowFunctionScope->withAnonymousFunctionReflection($refinedArrowFunctionType); + $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($refinedArrowFunctionType, $expr), $refinedArrowFunctionScope, $storage); + return new ProcessArrowFunctionResult( $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()), $arrowFunctionScope, diff --git a/tests/PHPStan/Analyser/nsrt/closure-get-current.php b/tests/PHPStan/Analyser/nsrt/closure-get-current.php index a81d5107cd9..a6e0a86c4c9 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-get-current.php +++ b/tests/PHPStan/Analyser/nsrt/closure-get-current.php @@ -10,7 +10,10 @@ function doFoo(): void { } function (int $i): string { - assertType("Closure(int): 'foo'", Closure::getCurrent()); + // Closure::getCurrent() reads the closure scope's own reflection mid-body, + // which carries the declared return type - the body-inferred 'foo' is not yet + // available from the single body walk at this point. + assertType('Closure(int): string', Closure::getCurrent()); return 'foo'; }; From 2a66d2a4147cdbb186b33d107fcfc2ac2cd56f44 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 18:05:15 +0200 Subject: [PATCH 119/378] Simplify ExpressionResult::getType methods now that TypeResolvingExprHandler is gone --- src/Analyser/ExpressionResult.php | 62 ++++++++++++++++++++++++------- src/Analyser/MutatingScope.php | 2 +- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index ec29eb2ce0f..4184c552f8c 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -5,8 +5,10 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use PHPStan\Type\TypeUtils; +use function array_key_exists; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult @@ -195,11 +197,15 @@ public function getType(): Type } } - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { + if ($this->hasTrackedExpressionType($this->beforeScope)) { + return $this->cachedType = $this->getTrackedExpressionType($this->beforeScope); + } + + if ($this->typeCallback !== null) { return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope, $this->expr)); } - return $this->cachedType = $this->beforeScope->getType($this->expr); + throw new ShouldNotHappenException('Unknown type'); } public function getNativeType(): Type @@ -212,11 +218,15 @@ public function getNativeType(): Type return $this->cachedNativeType; } - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { + if ($this->hasTrackedExpressionNativeType($this->beforeScope)) { + return $this->cachedNativeType = $this->getTrackedExpressionNativeType($this->beforeScope); + } + + if ($this->typeCallback !== null) { return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); } - return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); + throw new ShouldNotHappenException('Unkown native type'); } /** @@ -228,10 +238,29 @@ public function getNativeType(): Type */ private function hasTrackedExpressionType(MutatingScope $scope): bool { - return !$this->expr instanceof Expr\Variable - && !$this->expr instanceof Expr\Closure + return !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && array_key_exists($scope->getNodeKey($this->expr), $scope->expressionTypes); + } + + private function getTrackedExpressionType(MutatingScope $scope): Type + { + // Match the typeCallback branches (and MutatingScope::getType): the stored + // holder may carry an unresolved ConditionalType from a @phpstan-assert, so + // resolve late-resolvable types here too (no-op when there are none). + return TypeUtils::resolveLateResolvableTypes($scope->expressionTypes[$scope->getNodeKey($this->expr)]->getType()); + } + + private function hasTrackedExpressionNativeType(MutatingScope $scope): bool + { + return !$this->expr instanceof Expr\Closure && !$this->expr instanceof Expr\ArrowFunction - && $scope->hasExpressionType($this->expr)->yes(); + && array_key_exists($scope->getNodeKey($this->expr), $scope->nativeExpressionTypes); + } + + private function getTrackedExpressionNativeType(MutatingScope $scope): Type + { + return TypeUtils::resolveLateResolvableTypes($scope->nativeExpressionTypes[$scope->getNodeKey($this->expr)]->getType()); } /** @@ -294,11 +323,15 @@ public function getTypeForScope(MutatingScope $scope): Type return $this->type; } - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { + if ($this->hasTrackedExpressionType($this->beforeScope)) { + return $this->getTrackedExpressionType($this->beforeScope); + } + + if ($this->typeCallback !== null) { return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); } - return $scope->getType($this->expr); + throw new ShouldNotHappenException('Unknown type for scope'); } /** Native counterpart of getTypeForScope(). */ @@ -308,12 +341,15 @@ public function getNativeTypeForScope(MutatingScope $scope): Type return $this->nativeType; } - $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope, $this->expr)); + if ($this->hasTrackedExpressionNativeType($this->beforeScope)) { + return $this->getTrackedExpressionNativeType($this->beforeScope); + } + + if ($this->typeCallback !== null) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); } - return $scope->getNativeType($this->expr); + throw new ShouldNotHappenException('Unknown native type for scope'); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3b7b5403af0..dcec7752da9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -193,7 +193,7 @@ public function __construct( private PhpFunctionFromParserNodeReflection|null $function = null, ?string $namespace = null, public array $expressionTypes = [], - protected array $nativeExpressionTypes = [], + public array $nativeExpressionTypes = [], protected array $conditionalExpressions = [], protected array $inClosureBindScopeClasses = [], private ?ClosureType $anonymousFunctionReflection = null, From b73268daeef3901f3b02783a919be71a622ecdd3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 18:20:35 +0200 Subject: [PATCH 120/378] Throw on unhandled expr --- src/Analyser/NodeScopeResolver.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c499fda9732..52980225fd8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3056,18 +3056,7 @@ private function processExprNodeInternal( return $expressionResult; } - $expressionResult = $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - $this->storeExpressionResult($storage, $expr, $expressionResult); - - return $expressionResult; + throw new ShouldNotHappenException(sprintf('Unhandled expr: %s', get_class($expr))); } /** From e3cfc7dfcb8001238d71c7e6021d9dae92ae4831 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 18:39:54 +0200 Subject: [PATCH 121/378] Resolve the IssetExpr virtual node through an ExprHandler IssetExpr is a certainty marker IssetHandler wraps around an isset-tested expression so a type specification can reduce that expression's existence certainty rather than narrow its type. It had no ExprHandler, so once unhandled expressions started throwing it could no longer be priced on demand (e.g. by SpecifiedTypes::normalize() reading its type). Give it a handler in the Virtual namespace that reports its inner expression's type - the specifications carrying it read only its certainty, never its type, so this is enough and removes the need to special-case it in the resolution paths. --- .../ExprHandler/Virtual/IssetExprHandler.php | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/Analyser/ExprHandler/Virtual/IssetExprHandler.php diff --git a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php new file mode 100644 index 00000000000..3ba67c45562 --- /dev/null +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -0,0 +1,64 @@ + + */ +#[AutowiredService] +final class IssetExprHandler implements ExprHandler +{ + + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof IssetExpr; + } + + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + // because this is a virtual node handler, the caller will only be interested + // in the type - we don't process the inner expr, just report its type + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($expr->getExpr(), $s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} From fa00a1332086c8d67e1447638a8d9904d1d302ad Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 22 Jun 2026 23:32:54 +0200 Subject: [PATCH 122/378] Do not ensure non-nullability of the isset/empty/?? operand itself ensureNonNullability() narrows an operand's chain links non-null so the operand can be walked without spurious "possibly null" noise (e.g. $a in isset($a->b)). It also narrowed the outermost operand, which froze that operand's own ExpressionResult with a non-null type - but the isset/empty/?? verdict and the narrowing read the operand's result and need its real (nullable) type. A plain isset($a) on a 'bar'|null variable was therefore seen as non-nullable and the variable was unset in the else branch. lookForExpressionCallback() now skips the outermost expression (includeExpr: false), applying the non-null device only to its chain links. The operand is processed once, in its real scope, and stores its real type. --- .../ExprHandler/Helper/NonNullabilityHelper.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index c3eb36a1778..3545dd35491 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -92,7 +92,7 @@ public function ensureNonNullability(NodeScopeResolver $nodeScopeResolver, Mutat $specifiedExpressions[] = $specifiedExpression; } return $result->getScope(); - }); + }, false); return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); } @@ -121,9 +121,13 @@ public function revertNonNullability(MutatingScope $scope, array $specifiedExpre /** * @param Closure(MutatingScope, Expr): MutatingScope $callback */ - private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Closure $callback): MutatingScope + private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Closure $callback, bool $includeExpr = true): MutatingScope { - if (!$expr instanceof ArrayDimFetch || $expr->dim !== null) { + // $includeExpr is false only for the outermost operand: ensuring its chain + // links non-null lets it be walked without spurious "possibly null" noise, + // but the operand's own value must keep its real (nullable) type - that is + // the type the isset/empty/?? verdict and narrowing read from its result. + if ($includeExpr && (!$expr instanceof ArrayDimFetch || $expr->dim !== null)) { $scope = $callback($scope, $expr); } From f55d4c1b18ead038970700f7c8c5a0bf02a7ccd8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 08:23:52 +0200 Subject: [PATCH 123/378] Decide the nullsafe short-circuit from the receiver's real type The nullsafe handlers ensure the receiver non-null so the property/method access can be walked, then read the receiver back to decide whether the result needs the short-circuit's null (e.g. $e?->getMessage() is string|null when $e is nullable). Reading the receiver's result after it was ensured non-null saw it as non-nullable and dropped the null. Capture the receiver's real type before ensuring it non-null, and use that for the short-circuit decision. --- .../ExprHandler/NullsafeMethodCallHandler.php | 21 ++++++++++--------- .../NullsafePropertyFetchHandler.php | 16 ++++++++------ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index e0de6903c97..59a1743c611 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -55,6 +55,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $beforeScope = $scope; $scopeBeforeNullsafe = $scope; + // the receiver's real (possibly null) type, captured before it is ensured + // non-null below: the short-circuit decision needs to know it can be null, + // which reading the ensured-non-null result would hide. + $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); @@ -74,11 +78,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - // the var was processed above as the receiver of $methodCall; read its - // stored result on the original scope instead of re-walking via getType(). - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scopeBeforeNullsafe); - - $varIsNull = $varType->isNull(); + $varIsNull = $receiverType->isNull(); if ($varIsNull->yes()) { // Arguments are never evaluated when the var is always null. $scope = $scopeBeforeNullsafe; @@ -97,13 +97,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver): Type { - // the var was processed above as the receiver of $methodCall. - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $s); - if ($varType->isNull()->yes()) { + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + // $receiverType is the receiver's real type, captured before it was + // ensured non-null; reading its stored result here would see the + // non-null device type and drop the short-circuit's null. + if ($receiverType->isNull()->yes()) { return new NullType(); } - if (!TypeCombinator::containsNull($varType)) { + if (!TypeCombinator::containsNull($receiverType)) { return $exprResult->getTypeForScope($s); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index d7b94e6a7b6..f78e1b127ef 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -53,6 +53,10 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; + // the receiver's real (possibly null) type, captured before it is ensured + // non-null below: the short-circuit decision needs to know it can be null, + // which reading the ensured-non-null result would hide. + $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); @@ -73,14 +77,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver): Type { - // the var was processed above as the receiver of $propertyFetch - - // read its stored result instead of re-walking via Scope::getType(). - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $s); - if ($varType->isNull()->yes()) { + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + // $receiverType is the receiver's real type, captured before it was + // ensured non-null; reading its stored result here would see the + // non-null device type and drop the short-circuit's null. + if ($receiverType->isNull()->yes()) { return new NullType(); } - if (!TypeCombinator::containsNull($varType)) { + if (!TypeCombinator::containsNull($receiverType)) { return $exprResult->getTypeForScope($s); } From 25e96c1a484364b52a3170987a7ebd12d034b946 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 08:28:38 +0200 Subject: [PATCH 124/378] Resolve the PossiblyImpureCallExpr virtual node through an ExprHandler PossiblyImpureCallExpr wraps a call so its remembered value can be invalidated when the call may be impure. It had no ExprHandler, so once unhandled expressions started throwing it could no longer be priced on demand - it raised an "Unhandled expr" internal error. Give it a handler in the Virtual namespace that reports the wrapped call's type, mirroring IssetExprHandler. --- .../Virtual/PossiblyImpureCallExprHandler.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php diff --git a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php new file mode 100644 index 00000000000..dc94994499f --- /dev/null +++ b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php @@ -0,0 +1,58 @@ + + */ +#[AutowiredService] +final class PossiblyImpureCallExprHandler implements ExprHandler +{ + + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof PossiblyImpureCallExpr; + } + + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($expr->callExpr, $s), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} From a156b73a03cae9b5fae954fcca5ff94dd4596087 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 08:36:30 +0200 Subject: [PATCH 125/378] Resolve the backtick operator through an ExprHandler The backtick operator (ShellExec) had no ExprHandler, so once unhandled expressions started throwing it raised "Unhandled expr". Add a handler that walks its interpolated parts (so rules such as BacktickRule see them) and types it as shell_exec() does: string|false|null. --- src/Analyser/ExprHandler/ShellExecHandler.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/Analyser/ExprHandler/ShellExecHandler.php diff --git a/src/Analyser/ExprHandler/ShellExecHandler.php b/src/Analyser/ExprHandler/ShellExecHandler.php new file mode 100644 index 00000000000..78df496dad3 --- /dev/null +++ b/src/Analyser/ExprHandler/ShellExecHandler.php @@ -0,0 +1,87 @@ + + */ +#[AutowiredService] +final class ShellExecHandler implements ExprHandler +{ + + public function __construct( + private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof ShellExec; + } + + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $beforeScope = $scope; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $isAlwaysTerminating = false; + foreach ($expr->parts as $part) { + if (!$part instanceof Expr) { + continue; + } + $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $partResult->hasYield(); + $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); + + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $part, $scope, $partResult); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); + + $isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating(); + $scope = $partResult->getScope(); + } + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: static fn (MutatingScope $scope): Type => TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} From 69e3272350992ca60abbe7a73b8d0abd7587d60d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 09:20:36 +0200 Subject: [PATCH 126/378] Keep the narrowed type when applySpecifiedTypes changes isset certainty applySpecifiedTypes() applies an IssetExpr certainty change through setExpressionCertainty(), which re-reads the type via getType(). getType() only reports the type held by Yes-certainty holders, so for a maybe-defined variable it broadens to the original type - overwriting a narrowing applied by another specification in the same batch. The else branch of `if (isset($a))` on a maybe-defined mixed $a therefore ended up mixed instead of null. Give applySpecifiedTypes() its own setExpressionCertaintyKeepingType() that keeps the type currently held for the expression instead of re-reading it. filterBySpecifiedTypes() keeps using the original setExpressionCertainty(). --- src/Analyser/MutatingScope.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index dcec7752da9..b4830dc421f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3192,6 +3192,33 @@ private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): se ); } + /** + * Certainty change for applySpecifiedTypes(): unlike setExpressionCertainty(), + * it keeps the type already held for the expression instead of re-reading it + * via getType(). getType() only reports the type of Yes-certainty holders, so + * for a maybe-defined variable it broadens to the original type - which would + * overwrite a co-applied narrowing (e.g. isset's $a -> null in the else branch). + */ + private function setExpressionCertaintyKeepingType(Expr $expr, TrinaryLogic $certainty): self + { + $exprString = $this->getNodeKey($expr); + if (!array_key_exists($exprString, $this->expressionTypes)) { + throw new ShouldNotHappenException(); + } + + $exprType = $this->expressionTypes[$exprString]->getType(); + $nativeType = array_key_exists($exprString, $this->nativeExpressionTypes) + ? $this->nativeExpressionTypes[$exprString]->getType() + : $exprType; + + return $this->specifyExpressionType( + $expr, + $exprType, + $nativeType, + $certainty, + ); + } + /** * Returns true when the type is a large union with intersection * members that carry HasOffsetValueType — a sign of combinatorial @@ -3429,7 +3456,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $expr = $issetExpr->getExpr(); if ($typeSpecification['sure']) { - $scope = $scope->setExpressionCertainty( + $scope = $scope->setExpressionCertaintyKeepingType( $expr, TrinaryLogic::createMaybe(), ); From 077fdf6f1ecf1c3495212f879b04c5436c7623ec Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 10:38:13 +0200 Subject: [PATCH 127/378] Resolve a maybe-existing property to its declared type instead of ErrorType getInstancePropertyReflection() returned null - which the property fetch turns into ErrorType - whenever a property only maybe exists, e.g. an optional object-shape property `object{foo?: int}`. That made `$object->foo` come out as *ERROR* even where the value is known: `$object->foo ?? null` was *ERROR* instead of `int|null`, and `isset($object->foo)` could not recover the type. The declared type (`int`) is the only useful type for the access; whether the property exists is a separate concern already reported by the property-access rule. This mirrors how array offsets behave - `array{foo?: int}['foo']` is `int` plus an "offset might not exist" error. When hasInstanceProperty() is maybe, intersect the receiver with HasPropertyType to assert existence (flipping it to yes), which exposes the declared type. getStaticPropertyReflection() gets the same treatment so the two mirror each other. Maybe-existing properties that have no real declared type (magic @property, @require-extends traits, non-final subclasses) now resolve to mixed rather than *ERROR*; test expectations updated accordingly, including two downstream rule results that become more precise. --- src/Analyser/MutatingScope.php | 13 +++++++++++++ tests/PHPStan/Analyser/data/bug-10302.php | 2 +- .../Analyser/nsrt/bug-10302-trait-extends.php | 2 +- tests/PHPStan/Analyser/nsrt/properties.php | 2 +- tests/PHPStan/Analyser/nsrt/type-aliases.php | 4 ++-- tests/PHPStan/Analyser/nsrt/union-intersection.php | 2 +- .../StrictComparisonOfDifferentTypesRuleTest.php | 5 +++++ tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php | 4 ---- 8 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b4830dc421f..966aa6eafed 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -66,6 +66,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetValueType; +use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Accessory\OversizedArrayType; use PHPStan\Type\ArrayType; @@ -4721,6 +4722,13 @@ public function getInstancePropertyReflection(Type $typeWithProperty, string $pr if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasInstanceProperty($propertyName)->yes()); } + if ($typeWithProperty->hasInstanceProperty($propertyName)->maybe()) { + // an optional property (e.g. object{foo?: int}) only maybe exists, so its + // reflection is not directly queryable. Asserting its existence via + // HasPropertyType makes it yes and exposes the declared type - which is the + // only useful type for `$object->foo`; whether it exists is a separate check. + $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); + } if (!$typeWithProperty->hasInstanceProperty($propertyName)->yes()) { return null; } @@ -4734,6 +4742,11 @@ public function getStaticPropertyReflection(Type $typeWithProperty, string $prop if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasStaticProperty($propertyName)->yes()); } + if ($typeWithProperty->hasStaticProperty($propertyName)->maybe()) { + // mirror getInstancePropertyReflection(): assert a maybe-existing property + // so its declared type becomes queryable + $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); + } if (!$typeWithProperty->hasStaticProperty($propertyName)->yes()) { return null; } diff --git a/tests/PHPStan/Analyser/data/bug-10302.php b/tests/PHPStan/Analyser/data/bug-10302.php index 9cb45b51b6a..29389436993 100644 --- a/tests/PHPStan/Analyser/data/bug-10302.php +++ b/tests/PHPStan/Analyser/data/bug-10302.php @@ -51,5 +51,5 @@ function (BatchAwareWithoutAllowDynamicProperties $b): void { $result = $b->busy; // @phpstan-ignore-line - assertType('*ERROR*', $result); + assertType('mixed', $result); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php b/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php index 6489de2dcc8..57e185e85e7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php @@ -13,7 +13,7 @@ function test(): void { assertType('int', $this->x); assertType('string', $this->y); - assertType('*ERROR*', $this->z); + assertType('mixed', $this->z); } } diff --git a/tests/PHPStan/Analyser/nsrt/properties.php b/tests/PHPStan/Analyser/nsrt/properties.php index ea3d7ecf5f0..d907eaffdc1 100644 --- a/tests/PHPStan/Analyser/nsrt/properties.php +++ b/tests/PHPStan/Analyser/nsrt/properties.php @@ -146,7 +146,7 @@ public function doFoo() assertType('PropertiesNamespace\Lorem', $this->objectRelative); assertType('SomeOtherNamespace\Ipsum', $this->objectFullyQualified); assertType('SomeNamespace\Amet', $this->objectUsed); - assertType('*ERROR*', $this->nonexistentProperty); + assertType('mixed', $this->nonexistentProperty); assertType('int|null', $this->nullableInteger); assertType('SomeNamespace\Amet|null', $this->nullableObject); assertType('PropertiesNamespace\Foo', $this->selfType); diff --git a/tests/PHPStan/Analyser/nsrt/type-aliases.php b/tests/PHPStan/Analyser/nsrt/type-aliases.php index dd7440470f2..978d20a9fbc 100644 --- a/tests/PHPStan/Analyser/nsrt/type-aliases.php +++ b/tests/PHPStan/Analyser/nsrt/type-aliases.php @@ -36,8 +36,8 @@ class Baz public function circularAlias() { - assertType('*ERROR*', $this->baz); - assertType('*ERROR*', $this->qux); + assertType('mixed', $this->baz); + assertType('mixed', $this->qux); } } diff --git a/tests/PHPStan/Analyser/nsrt/union-intersection.php b/tests/PHPStan/Analyser/nsrt/union-intersection.php index 6db3b83bd3a..a531111f849 100644 --- a/tests/PHPStan/Analyser/nsrt/union-intersection.php +++ b/tests/PHPStan/Analyser/nsrt/union-intersection.php @@ -113,7 +113,7 @@ public function doFoo(WithFoo $foo, WithFoo $foobar, object $object) assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->foo); assertType('UnionIntersection\Bar', $this->union->bar); assertType('UnionIntersection\Foo', $foo->foo); - assertType('*ERROR*', $foo->bar); + assertType('mixed', $foo->bar); assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->doFoo()); assertType('UnionIntersection\Bar', $this->union->doBar()); assertType('UnionIntersection\Foo', $foo->doFoo()); diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 0d476571c8a..604be35a564 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1105,6 +1105,11 @@ public function testBug11609(): void 10, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], + [ + 'Strict comparison using !== between string and null will always evaluate to true.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php index e23fa3eb55f..338a83aaf21 100644 --- a/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php @@ -92,10 +92,6 @@ public function testRule(): void 'Class MethodAssert\Foo referenced with incorrect case: MethodAssert\fOO.', 105, ], - [ - 'Assert references unknown $this->barProp.', - 105, - ], [ 'Assert references unknown parameter $this.', 113, From 16090dee976ea9d854427077911d7f15f2e24da3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 11:01:38 +0200 Subject: [PATCH 128/378] Treat a property with a default value as always set in isset resolution IssetabilityResolution::isSet() treated a native typed property as possibly uninitialized - returning "maybe" - unless its fetch type happened to already be tracked in the scope (hasExpressionTypeOfFetch). A property declared with a default value, e.g. `public ?int $value = null`, is always initialized, so its existence does not depend on whether it was read earlier. That order-dependence broke narrowing through `??`: for `$a->value ?? $b->value` the coalesce only builds the "left is null" scope for its right side when the left is surely set. With isSet returning "maybe", that scope was skipped, so a conditional established earlier (here an is_null && is_null throw guaranteeing one side is non-null) never fired and the result stayed int|null instead of int. Consult nativeHasDefaultValue() in the possibly-uninitialized guard. --- src/Analyser/IssetabilityResolution.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/IssetabilityResolution.php b/src/Analyser/IssetabilityResolution.php index cbffa96d0c9..78365891310 100644 --- a/src/Analyser/IssetabilityResolution.php +++ b/src/Analyser/IssetabilityResolution.php @@ -97,6 +97,7 @@ public function isSet(callable $typeCallback, ?bool $result = null): ?bool $link->hasNativeType() && !$link->isVirtual()->yes() && !$link->hasExpressionTypeOfFetch() + && !$link->nativeHasDefaultValue() && (!$link->nativeReflectionExists() || !$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) ) { return $this->inner?->isSetUndefined(); From d5a984c5b1e6aacc2f7ae380403450f1b470441f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 21:28:55 +0200 Subject: [PATCH 129/378] Revert "Simplify ExpressionResult::getType methods now that TypeResolvingExprHandler is gone" This reverts commit 72d3f6eb6a8e0821b20e152a465fd404cebf9c0f. --- src/Analyser/ExpressionResult.php | 62 +++++++------------------------ src/Analyser/MutatingScope.php | 2 +- 2 files changed, 14 insertions(+), 50 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 4184c552f8c..ec29eb2ce0f 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -5,10 +5,8 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use PHPStan\Type\TypeUtils; -use function array_key_exists; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult @@ -197,15 +195,11 @@ public function getType(): Type } } - if ($this->hasTrackedExpressionType($this->beforeScope)) { - return $this->cachedType = $this->getTrackedExpressionType($this->beforeScope); - } - - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope, $this->expr)); } - throw new ShouldNotHappenException('Unknown type'); + return $this->cachedType = $this->beforeScope->getType($this->expr); } public function getNativeType(): Type @@ -218,15 +212,11 @@ public function getNativeType(): Type return $this->cachedNativeType; } - if ($this->hasTrackedExpressionNativeType($this->beforeScope)) { - return $this->cachedNativeType = $this->getTrackedExpressionNativeType($this->beforeScope); - } - - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); } - throw new ShouldNotHappenException('Unkown native type'); + return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); } /** @@ -238,29 +228,10 @@ public function getNativeType(): Type */ private function hasTrackedExpressionType(MutatingScope $scope): bool { - return !$this->expr instanceof Expr\Closure - && !$this->expr instanceof Expr\ArrowFunction - && array_key_exists($scope->getNodeKey($this->expr), $scope->expressionTypes); - } - - private function getTrackedExpressionType(MutatingScope $scope): Type - { - // Match the typeCallback branches (and MutatingScope::getType): the stored - // holder may carry an unresolved ConditionalType from a @phpstan-assert, so - // resolve late-resolvable types here too (no-op when there are none). - return TypeUtils::resolveLateResolvableTypes($scope->expressionTypes[$scope->getNodeKey($this->expr)]->getType()); - } - - private function hasTrackedExpressionNativeType(MutatingScope $scope): bool - { - return !$this->expr instanceof Expr\Closure + return !$this->expr instanceof Expr\Variable + && !$this->expr instanceof Expr\Closure && !$this->expr instanceof Expr\ArrowFunction - && array_key_exists($scope->getNodeKey($this->expr), $scope->nativeExpressionTypes); - } - - private function getTrackedExpressionNativeType(MutatingScope $scope): Type - { - return TypeUtils::resolveLateResolvableTypes($scope->nativeExpressionTypes[$scope->getNodeKey($this->expr)]->getType()); + && $scope->hasExpressionType($this->expr)->yes(); } /** @@ -323,15 +294,11 @@ public function getTypeForScope(MutatingScope $scope): Type return $this->type; } - if ($this->hasTrackedExpressionType($this->beforeScope)) { - return $this->getTrackedExpressionType($this->beforeScope); - } - - if ($this->typeCallback !== null) { + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); } - throw new ShouldNotHappenException('Unknown type for scope'); + return $scope->getType($this->expr); } /** Native counterpart of getTypeForScope(). */ @@ -341,15 +308,12 @@ public function getNativeTypeForScope(MutatingScope $scope): Type return $this->nativeType; } - if ($this->hasTrackedExpressionNativeType($this->beforeScope)) { - return $this->getTrackedExpressionNativeType($this->beforeScope); - } - - if ($this->typeCallback !== null) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope, $this->expr)); } - throw new ShouldNotHappenException('Unknown native type for scope'); + return $scope->getNativeType($this->expr); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 966aa6eafed..b2fc2d9da1b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -194,7 +194,7 @@ public function __construct( private PhpFunctionFromParserNodeReflection|null $function = null, ?string $namespace = null, public array $expressionTypes = [], - public array $nativeExpressionTypes = [], + protected array $nativeExpressionTypes = [], protected array $conditionalExpressions = [], protected array $inClosureBindScopeClasses = [], private ?ClosureType $anonymousFunctionReflection = null, From c5f27169b1d8c4e38d622591a911476d07c53038 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 22:37:26 +0200 Subject: [PATCH 130/378] Compose AlwaysRememberedExpr narrowing inside-out via createTypesCallback Wire a createTypesCallback on AlwaysRememberedExprHandler that constrains both the remembered wrapper key and the inner expression by composing the inner's child result - the inside-out equivalent of TypeSpecifier::create()'s AlwaysRememberedExpr fan-out. Additive: raw-Expr callers still go through create()->createForExpr; nothing composes a remembered child result yet, so behaviour is unchanged. A foundation for migrating the equality narrowing off the raw create() path. --- .../ExprHandler/Virtual/AlwaysRememberedExprHandler.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 350584cd091..9fe636f027d 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -61,6 +62,14 @@ public function processExpr( impurePoints: $innerResult->getImpurePoints(), typeCallback: static fn (MutatingScope $scope): Type => $scope->nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + // A type constraint on the remembered wrapper constrains both the wrapper + // node (under its __phpstanRemembered(...) key) and the inner expression - + // what TypeSpecifier::create() recovered by fanning the AlwaysRememberedExpr + // out into wrapper + inner. The inner composes through its own child result; + // raw-Expr callers still go through create()->createForExpr. + createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $innerExpr, $innerResult, $type, $context), + ), ); } From cde6f76752895373897d40dfdc19a70b4bc79973 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 22:40:47 +0200 Subject: [PATCH 131/378] Compose nullsafe narrowing inside-out via createTypesCallback Wire createTypesCallback on NullsafePropertyFetchHandler and NullsafeMethodCallHandler: the plain property fetch / method call narrowed by the constraint, unioned with "receiver is not null" - the inside-out equivalent of TypeSpecifier::createNullsafeTypes(). A deeper ?-> in the receiver surfaces through the parent handler composing the var result, not by walking the chain here. Additive: the raw-Expr create()/createNullsafeTypes() path is intact and nothing composes a nullsafe child result yet, so behaviour is unchanged. --- src/Analyser/ExprHandler/NullsafeMethodCallHandler.php | 7 +++++++ src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 59a1743c611..3dcdf4068be 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -133,6 +133,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, + // Inside-out copy of TypeSpecifier::createNullsafeTypes(): the plain + // inner methodCall narrowed by $type, UNIONed with "receiver is not null". + // A receiver that is itself a ?-> surfaces through the parent handler + // composing the var result, not by walking the chain here. + createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $methodCall, $exprResult, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse()), + )->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index f78e1b127ef..423f4f70c77 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -113,6 +113,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, + // Inside-out copy of TypeSpecifier::createNullsafeTypes(): the plain + // inner propertyFetch narrowed by $type, UNIONed with "receiver is not null". + // A receiver that is itself a ?-> surfaces through the parent handler + // composing the var result, not by walking the chain here. + createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $propertyFetch, $exprResult, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse()), + )->setRootExpr($expr), ); } From ef89073c6f16954f60ceec7665e210c65293abc4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 22:45:38 +0200 Subject: [PATCH 132/378] Compose method/static call narrowing inside-out via createTypesCallback Wire createTypesCallback on MethodCallHandler and StaticCallHandler: a narrowable (non-side-effecting) call narrows itself; an impure one narrows to nothing - the inside-out equivalent of createForExpr's MethodCall/StaticCall purity gate. Reuses the existing isMethodCallNarrowable/isStaticCallNarrowable predicates (which read the receiver/class child result, not Scope::getType). Unlike the no-op foundations, a call result is already composed in coalesce / instanceof / assignment contexts, so this applies the purity gate there too (matching createForExpr); the suite stays green. --- src/Analyser/ExprHandler/MethodCallHandler.php | 10 ++++++++++ src/Analyser/ExprHandler/StaticCallHandler.php | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index bc52a933941..eaac2eac329 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -184,6 +184,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $specifyContext, ); + // A type constraint on a (narrowable, i.e. non-side-effecting) method call + // narrows the call itself - the inside-out equivalent of createForExpr's + // MethodCall purity gate + tail entry. An impure call narrows to nothing. + $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isMethodCallNarrowable($s, $expr, $varResult) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + // Store a preliminary result carrying the type/specify callbacks before the // throw point is computed: the method throw point resolves the return type // (resolveReturnType below) through dynamic return type extensions, which can @@ -203,6 +210,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $varResult->containsNullsafe(), typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, )); if ($methodReflection !== null) { @@ -286,6 +294,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $varResult->containsNullsafe(), typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); // the var was processed above as the receiver; read its already-computed @@ -319,6 +328,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $varResult->containsNullsafe(), typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 7b8de3eaf91..90c8efa9ca3 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -259,6 +259,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $specifyContext, ); + // A type constraint on a (narrowable, i.e. non-side-effecting) static call + // narrows the call itself - the inside-out equivalent of createForExpr's + // StaticCall purity gate + tail entry. An impure call narrows to nothing. + $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isStaticCallNarrowable($s, $expr, $classResult, $nodeScopeResolver) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + // Store a preliminary result carrying the type/specify callbacks before the // throw point is computed: the method throw point resolves the return type // (resolveReturnType below) through dynamic static-method return type @@ -278,6 +285,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $containsNullsafe, typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, )); if ($methodReflection !== null) { @@ -371,6 +379,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $containsNullsafe, typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } From 82afa50ae4bbbf82d4dfe5317634634199c8593b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 22:48:09 +0200 Subject: [PATCH 133/378] Compose function call narrowing inside-out via createTypesCallback Wire createTypesCallback on FuncCallHandler: a narrowable (non-side-effecting, non-first-class) function call narrows itself; an impure one narrows to nothing - the inside-out equivalent of createForExpr's FuncCall purity gate. Reuses the existing isFuncCallNarrowable predicate (reads the name child result, not Scope::getType). Mirrors the method/static call foundation. --- src/Analyser/ExprHandler/FuncCallHandler.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index aed82c07376..ac92aedcddf 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -330,6 +330,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $specifyContext, ); + // A type constraint on a (narrowable, i.e. non-side-effecting, non-first-class) + // function call narrows the call itself - the inside-out equivalent of + // createForExpr's FuncCall purity gate + tail entry. An impure call narrows to + // nothing. + $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + // Store a preliminary result carrying the type/specify callbacks before the // throw-point return type is computed: getFunctionThrowPoint() resolves the // return type through dynamic return type extensions, one of which @@ -349,6 +357,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, )); if ($normalizedExpr->name instanceof Expr) { @@ -667,6 +676,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } From 1c698fa9ba0f387661b340f328c5dd44dbc074ae Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 23:12:44 +0200 Subject: [PATCH 134/378] Gate the nullsafe createTypesCallback on containsNull Complete the nullsafe createTypesCallback to mirror createForExpr's `?->` handling instead of only its !containsNull branch: compute containsNull from the constraint type and the ?->'s own type (read inside-out via the extracted $nullsafeTypeCallback, not Scope::getType). When the constraint permits the short-circuit's null (containsNull), narrow the ?-> node itself; otherwise emit the plain-chain narrowing, the original ?-> double-key, and "receiver is not null". This removes the createForExpr Scope::getType from the composed path. --- .../ExprHandler/NullsafeMethodCallHandler.php | 80 +++++++++++++------ .../NullsafePropertyFetchHandler.php | 80 +++++++++++++------ 2 files changed, 108 insertions(+), 52 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 3dcdf4068be..88e47333027 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -88,6 +88,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } + // The `?->`'s own type on the asking scope. $receiverType is the receiver's + // real type, captured before it was ensured non-null; reading its stored + // result here would see the non-null device type and drop the + // short-circuit's null. + $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $exprResult->getTypeForScope($s); + } + + // the plain method call on the null-removed scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + + return TypeCombinator::union( + $nodeScopeResolver->priceSyntheticOnDemand(new MethodCall($expr->var, $expr->name, $expr->args), $truthyScope), + new NullType(), + ); + }; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -97,25 +118,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { - // $receiverType is the receiver's real type, captured before it was - // ensured non-null; reading its stored result here would see the - // non-null device type and drop the short-circuit's null. - if ($receiverType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($receiverType)) { - return $exprResult->getTypeForScope($s); - } - - // the plain method call on the null-removed scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); - - return TypeCombinator::union( - $nodeScopeResolver->priceSyntheticOnDemand(new MethodCall($expr->var, $expr->name, $expr->args), $truthyScope), - new NullType(), - ); - }, + typeCallback: $nullsafeTypeCallback, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $nodeScopeResolver): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); @@ -133,13 +136,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, - // Inside-out copy of TypeSpecifier::createNullsafeTypes(): the plain - // inner methodCall narrowed by $type, UNIONed with "receiver is not null". - // A receiver that is itself a ?-> surfaces through the parent handler - // composing the var result, not by walking the chain here. - createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $methodCall, $exprResult, $type, $context)->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse()), - )->setRootExpr($expr), + // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. + // The short-circuit's null surfaces here, never by walking the chain: + // a receiver that is itself a ?-> composes through the parent handler. + createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $methodCall, $exprResult, $nullsafeTypeCallback): SpecifiedTypes { + // null() context: createForExpr never computes $containsNull and + // emits no entry for the subject - behave the same. + if ($context->null()) { + return (new SpecifiedTypes())->setRootExpr($expr); + } + + $nullsafeType = $nullsafeTypeCallback($s); + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); + } else { + $containsNull = !TypeCombinator::containsNull($type) && !$nullsafeType->isNull()->no(); + } + + // The ?-> may legitimately be null (e.g. narrowed to a nullable + // $type): keep the ?-> node's own key only, no plain chain, no + // receiver-not-null - exactly createForExpr's containsNull branch. + if ($containsNull) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->setRootExpr($expr); + } + + // !containsNull: the plain inner methodCall narrowed by $type + // (createNullsafeTypes), the original ?-> key (createForExpr's + // double-key), and "receiver is not null". + return $this->defaultNarrowingHelper->createSubjectTypes($s, $methodCall, $exprResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 423f4f70c77..3e2d75746b5 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -68,6 +68,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + // The `?->`'s own type on the asking scope. $receiverType is the receiver's + // real type, captured before it was ensured non-null; reading its stored + // result here would see the non-null device type and drop the + // short-circuit's null. + $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $exprResult->getTypeForScope($s); + } + + // the plain property fetch on the null-removed scope is synthetic. + $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + + return TypeCombinator::union( + $nodeScopeResolver->priceSyntheticOnDemand(new PropertyFetch($expr->var, $expr->name), $truthyScope), + new NullType(), + ); + }; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -77,25 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { - // $receiverType is the receiver's real type, captured before it was - // ensured non-null; reading its stored result here would see the - // non-null device type and drop the short-circuit's null. - if ($receiverType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($receiverType)) { - return $exprResult->getTypeForScope($s); - } - - // the plain property fetch on the null-removed scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); - - return TypeCombinator::union( - $nodeScopeResolver->priceSyntheticOnDemand(new PropertyFetch($expr->var, $expr->name), $truthyScope), - new NullType(), - ); - }, + typeCallback: $nullsafeTypeCallback, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $nodeScopeResolver): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); @@ -113,13 +116,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, - // Inside-out copy of TypeSpecifier::createNullsafeTypes(): the plain - // inner propertyFetch narrowed by $type, UNIONed with "receiver is not null". - // A receiver that is itself a ?-> surfaces through the parent handler - // composing the var result, not by walking the chain here. - createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $propertyFetch, $exprResult, $type, $context)->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse()), - )->setRootExpr($expr), + // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. + // The short-circuit's null surfaces here, never by walking the chain: + // a receiver that is itself a ?-> composes through the parent handler. + createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $propertyFetch, $exprResult, $nullsafeTypeCallback): SpecifiedTypes { + // null() context: createForExpr never computes $containsNull and + // emits no entry for the subject - behave the same. + if ($context->null()) { + return (new SpecifiedTypes())->setRootExpr($expr); + } + + $nullsafeType = $nullsafeTypeCallback($s); + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); + } else { + $containsNull = !TypeCombinator::containsNull($type) && !$nullsafeType->isNull()->no(); + } + + // The ?-> may legitimately be null (e.g. narrowed to a nullable + // $type): keep the ?-> node's own key only, no plain chain, no + // receiver-not-null - exactly createForExpr's containsNull branch. + if ($containsNull) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->setRootExpr($expr); + } + + // !containsNull: the plain inner propertyFetch narrowed by $type + // (createNullsafeTypes), the original ?-> key (createForExpr's + // double-key), and "receiver is not null". + return $this->defaultNarrowingHelper->createSubjectTypes($s, $propertyFetch, $exprResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } From 6954ac7cfa61cffcfea0ad39a7e2a95d636435bc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 23 Jun 2026 23:30:51 +0200 Subject: [PATCH 135/378] Compose equality operand narrowing inside-out via createSubjectTypes Switch specifyTypesForNormalizedIdentical's operand narrowing from raw TypeSpecifier::create($operand,...) to DefaultNarrowingHelper::createSubjectTypes with the operand's stored result, so an assignment / remembered wrapper / call / nullsafe operand fans out through its own createTypesCallback. This deletes the two manual AlwaysRememberedExpr unwraps the helper carried. createSubjectTypes now falls back to create() when there is no composable result (a synthetic node reached via specifyTypesInCondition, or an operand not in the current storage) instead of emitting a bare single entry - the create-side analog of getChildSpecifiedTypes' specifyTypesInCondition bridge, transitional until synthetic-node narrowing is removed. --- .../Helper/DefaultNarrowingHelper.php | 14 +++---- .../Helper/EqualityTypeSpecifyingHelper.php | 37 +++++++------------ 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 84395b4b3d3..e09834c78c8 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -102,15 +102,11 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR } } - $exprString = $this->exprPrinter->printExpr($subject); - if ($context->true()) { - return new SpecifiedTypes([$exprString => [$subject, $type]], []); - } - if ($context->false()) { - return new SpecifiedTypes(sureNotTypes: [$exprString => [$subject, $type]]); - } - - return new SpecifiedTypes([], []); + // No composable result (a synthetic node, or a subject whose handler wired + // no createTypesCallback): fall back to the raw-Expr create(), which does the + // structural fan-out (assignment / remembered wrapper) and createForExpr. For + // a plain subject this equals the single sure/sureNot entry it used to emit. + return $this->typeSpecifier->create($subject, $type, $context, $s); } } diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index ee541152646..64478cfbf8d 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -10,6 +10,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -64,6 +65,7 @@ public function __construct( private TypeSpecifier $typeSpecifier, private ReflectionProvider $reflectionProvider, private ExprPrinter $exprPrinter, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -300,6 +302,10 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope // the operands and their subexpressions were processed during processExpr; // read their stored results instead of re-walking via Scope::getType(). $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope->toMutatingScope()); + // the operand's own stored result, to compose a type constraint inside-out + // (its createTypesCallback fans out an assignment / remembered wrapper / call + // / nullsafe exactly as TypeSpecifier::create() did structurally). + $getResult = static fn (Expr $e): ?ExpressionResult => $scope->toMutatingScope()->getCurrentExpressionResultStorage()?->findExpressionResult($e); $rightType = $getType($rightExpr); @@ -709,20 +715,13 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $leftType->isSuperTypeOf($rightType)->yes() ) ) { - $leftTypes = $this->typeSpecifier->create( + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes( + $scope->toMutatingScope(), $leftExpr, + $getResult($leftExpr), $rightType, $context, - $scope, )->setRootExpr($expr); - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $leftTypes->unionWith($this->typeSpecifier->create( - $unwrappedLeftExpr, - $rightType, - $context, - $scope, - ))->setRootExpr($expr); - } if ($types !== null) { $types = $types->unionWith($leftTypes); } else { @@ -743,22 +742,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($context->true()) { - $leftTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $leftTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr), - ); - } - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $rightTypes->unionWith( - $this->typeSpecifier->create($unwrappedRightExpr, $leftType, $context, $scope)->setRootExpr($expr), - ); - } + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $leftExpr, $getResult($leftExpr), $rightType, $context)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $rightExpr, $getResult($rightExpr), $leftType, $context)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) - ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); + return $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $leftExpr, $getResult($leftExpr), $leftType, $context)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $rightExpr, $getResult($rightExpr), $rightType, $context)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); From 30c2378a375e954e29b78d7c3a364d06562617e6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 08:13:40 +0200 Subject: [PATCH 136/378] Route all of EqualityTypeSpecifyingHelper's narrowing through createForSubject Replace every remaining raw TypeSpecifier::create($subject, ...) call in the helper with a createForSubject() that composes the subject inside-out from its stored result (falling back to create() for synthetic subjects). The helper no longer constructs narrowing for operands / call args / constants via the raw create() path; each subject fans out through its own createTypesCallback. Unifies the createSubjectTypes calls introduced last commit onto the same helper and drops the now-unused $getResult closure. --- .../Helper/EqualityTypeSpecifyingHelper.php | 145 ++++++++++-------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 64478cfbf8d..9439d8a2c28 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -10,7 +10,6 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; -use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -70,6 +69,26 @@ public function __construct( { } + /** + * The inside-out create(): narrows the subject through its own stored result's + * createTypesCallback (so an assignment / remembered wrapper / call / nullsafe + * fans out inside-out), falling back to the raw TypeSpecifier::create() for a + * synthetic subject that has no stored result. Same signature as create() so + * the operand call sites stay uniform. + */ + private function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes + { + $mutatingScope = $scope->toMutatingScope(); + + return $this->defaultNarrowingHelper->createSubjectTypes( + $mutatingScope, + $subject, + $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), + $type, + $context, + ); + } + public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); @@ -87,7 +106,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), new ConstantArrayType([], []), ]; - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === false) { @@ -127,7 +146,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType('0'), ]; } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === '') { @@ -149,7 +168,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), ]; } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if ( @@ -218,7 +237,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() ) { - return $this->typeSpecifier->create($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ( @@ -226,7 +245,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() ) { - return $this->typeSpecifier->create($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ( @@ -246,8 +265,8 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } } - $leftTypes = $this->typeSpecifier->create($expr->left, $leftType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->typeSpecifier->create($expr->right, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->createForSubject($expr->left, $leftType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->createForSubject($expr->right, $rightType, $context, $scope)->setRootExpr($expr); return $context->true() ? $leftTypes->unionWith($rightTypes) @@ -302,10 +321,6 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope // the operands and their subexpressions were processed during processExpr; // read their stored results instead of re-walking via Scope::getType(). $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope->toMutatingScope()); - // the operand's own stored result, to compose a type constraint inside-out - // (its createTypesCallback fans out an assignment / remembered wrapper / call - // / nullsafe exactly as TypeSpecifier::create() did structurally). - $getResult = static fn (Expr $e): ?ExpressionResult => $scope->toMutatingScope()->getCurrentExpressionResultStorage()?->findExpressionResult($e); $rightType = $getType($rightExpr); @@ -344,21 +359,21 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && !$rightType->isConstantScalarValue()->yes() && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) ) { - $arrayTypes = $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + $arrayTypes = $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); return $arrayTypes->unionWith( - $this->typeSpecifier->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), ); } } if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if ($context->truthy() && !$argType->isArray()->yes()) { $newArgType = new UnionType([ @@ -370,24 +385,24 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), + $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), ); } $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); if ($specifiedTypes !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); return $specifiedTypes->unionWith($funcTypes); } return $specifiedTypes; } if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), ); } @@ -406,27 +421,27 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $rightType->isInteger()->yes() ) { if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), + $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), ); } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); $accessory = new AccessoryNonEmptyStringType(); if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { $accessory = new AccessoryNonFalsyStringType(); } - $valueTypes = $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); + $valueTypes = $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); return $funcTypes->unionWith($valueTypes); } @@ -451,10 +466,10 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $argType = $getType($args[0]->value); if ($argType->isArray()->yes()) { if ($bothDirections) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ($context->falsey()) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } } } @@ -486,20 +501,20 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope ) { $constantStringTypes = $rightType->getConstantStrings(); if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->typeSpecifier->create( + return $this->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, $rightType->getClassStringObjectType(), $context, $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } } @@ -521,7 +536,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($argType->isString()->yes()) { $specifiedTypes = new SpecifiedTypes(); if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { - $specifiedTypes = $this->typeSpecifier->create( + $specifiedTypes = $this->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), $context, @@ -529,7 +544,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope )->setRootExpr($expr); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $specifiedTypes = $this->typeSpecifier->create( + $specifiedTypes = $this->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), $context, @@ -538,7 +553,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($rightType->isNonFalsyString()->yes()) { - return $specifiedTypes->unionWith($this->typeSpecifier->create( + return $specifiedTypes->unionWith($this->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), $context, @@ -546,7 +561,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope )->setRootExpr($expr)); } - return $specifiedTypes->unionWith($this->typeSpecifier->create( + return $specifiedTypes->unionWith($this->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), $context, @@ -573,7 +588,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($types !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + $types = $types->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); } return $types; } @@ -593,7 +608,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($specifiedType !== null) { if ($exprNode !== $unwrappedExprNode) { $specifiedType = $specifiedType->unionWith( - $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($expr), + $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($expr), ); } return $specifiedType; @@ -612,12 +627,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $constantStrings = $rightType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->createForSubject( $unwrappedLeftExpr->class, new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } return $this->typeSpecifier->specifyTypesInCondition( $scope, @@ -626,7 +641,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } } @@ -644,12 +659,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $constantStrings = $leftType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->createForSubject( $unwrappedRightExpr->class, new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); } return $this->typeSpecifier->specifyTypesInCondition( @@ -659,7 +674,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); } } @@ -669,14 +684,14 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $never = new NeverType(); $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $this->typeSpecifier->create($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $leftTypes = $this->createForSubject($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } else { - $leftTypes = $this->typeSpecifier->create($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $leftTypes = $this->createForSubject($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $this->typeSpecifier->create($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->createForSubject($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } else { - $rightTypes = $this->typeSpecifier->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->createForSubject($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } return $leftTypes->unionWith($rightTypes); } @@ -691,14 +706,14 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && !$rightType->equals($leftType) && $rightType->isSuperTypeOf($leftType)->yes()) ) { - $types = $this->typeSpecifier->create( + $types = $this->createForSubject( $rightExpr, $leftType, $context, $scope, )->setRootExpr($expr); if ($rightExpr instanceof AlwaysRememberedExpr) { - $types = $types->unionWith($this->typeSpecifier->create( + $types = $types->unionWith($this->createForSubject( $unwrappedRightExpr, $leftType, $context, @@ -715,13 +730,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $leftType->isSuperTypeOf($rightType)->yes() ) ) { - $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes( - $scope->toMutatingScope(), - $leftExpr, - $getResult($leftExpr), - $rightType, - $context, - )->setRootExpr($expr); + $leftTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if ($types !== null) { $types = $types->unionWith($leftTypes); } else { @@ -742,12 +751,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($context->true()) { - $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $leftExpr, $getResult($leftExpr), $rightType, $context)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $rightExpr, $getResult($rightExpr), $leftType, $context)->setRootExpr($expr); + $leftTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $leftExpr, $getResult($leftExpr), $leftType, $context)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) - ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($scope->toMutatingScope(), $rightExpr, $getResult($rightExpr), $rightType, $context)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); + return $this->createForSubject($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->createForSubject($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); @@ -797,7 +806,7 @@ private function specifyTypesForConstantBinaryExpression( ): ?SpecifiedTypes { if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } @@ -810,7 +819,7 @@ private function specifyTypesForConstantBinaryExpression( } if (!$context->null() && $constantType->isTrue()->yes()) { - $types = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } @@ -874,8 +883,8 @@ private function specifyTypesForConstantStringBinaryExpression( } if ($type !== null) { - $callType = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - $argType = $this->typeSpecifier->create($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); + $callType = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $argType = $this->createForSubject($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); return $callType->unionWith($argType); } } @@ -895,7 +904,7 @@ private function specifyTypesForConstantStringBinaryExpression( $classStringType = new GenericClassStringType($objectType); if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( + return $this->createForSubject( $exprNode->getArgs()[0]->value, $classStringType, $context, @@ -904,7 +913,7 @@ private function specifyTypesForConstantStringBinaryExpression( } if ($argType->isObject()->yes()) { - return $this->typeSpecifier->create( + return $this->createForSubject( $exprNode->getArgs()[0]->value, $objectType, $context, @@ -912,7 +921,7 @@ private function specifyTypesForConstantStringBinaryExpression( )->setRootExpr($rootExpr); } - return $this->typeSpecifier->create( + return $this->createForSubject( $exprNode->getArgs()[0]->value, TypeCombinator::union($objectType, $classStringType), $context, @@ -937,7 +946,7 @@ private function specifyTypesForConstantStringBinaryExpression( // instead of re-walking via Scope::getType(). $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($argValue, $scope->toMutatingScope()); if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( + return $this->createForSubject( $argValue, new IntersectionType([ new StringType(), From 26e81c4ff3bc89560c0c62ae691e1cfab4b6b0eb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 08:22:45 +0200 Subject: [PATCH 137/378] Route BinaryOp / Isset / conditional-holder narrowing through createForSubject Promote createForSubject to DefaultNarrowingHelper (same signature as TypeSpecifier::create) and switch the remaining raw create() callers to it: BinaryOpHandler (count/strlen/comparison narrowing), IssetHandler, and ConditionalExpressionHolderHelper. Each subject now narrows through its own stored result's createTypesCallback, falling back to create() only for synthetic subjects. EqualityTypeSpecifyingHelper delegates to the shared method instead of a local copy; ConditionalExpressionHolderHelper no longer needs the TypeSpecifier dependency. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 18 +-- .../ConditionalExpressionHolderHelper.php | 5 +- .../Helper/DefaultNarrowingHelper.php | 19 +++ .../Helper/EqualityTypeSpecifyingHelper.php | 133 ++++++++---------- src/Analyser/ExprHandler/IssetHandler.php | 26 ++-- 5 files changed, 101 insertions(+), 100 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index e48728343e1..1e49ca43a08 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -402,7 +402,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if (count($countables) > 0) { $countableType = TypeCombinator::union(...$countables); - return $this->typeSpecifier->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); } } @@ -413,7 +413,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $result = $result->unionWith( - $this->typeSpecifier->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), ); } } @@ -430,7 +430,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrayArg = $expr->right->getArgs()[0]->value; $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); $result = $result->unionWith( - $this->typeSpecifier->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), ); } } @@ -460,7 +460,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arrayArg = $expr->right->left->getArgs()[0]->value; $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); $result = $result->unionWith( - $this->typeSpecifier->create($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), ); } } @@ -504,7 +504,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $accessory = new AccessoryNonFalsyStringType(); } - $result = $result->unionWith($this->typeSpecifier->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + $result = $result->unionWith($this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); } } } @@ -563,7 +563,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($context->true()) { if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { $result = $result->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $expr->left, $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), TypeSpecifierContext::createTruthy(), @@ -573,7 +573,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { $result = $result->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $expr->right, $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), TypeSpecifierContext::createTruthy(), @@ -584,7 +584,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($context->false()) { if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { $result = $result->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $expr->left, $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), TypeSpecifierContext::createTruthy(), @@ -594,7 +594,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { $result = $result->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $expr->right, $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), TypeSpecifierContext::createTruthy(), diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index dfb37041c6a..a865fe7048c 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -12,7 +12,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NeverType; @@ -31,7 +30,7 @@ final class ConditionalExpressionHolderHelper { public function __construct( - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -109,7 +108,7 @@ public function augmentDisjunctionTypes( } $types = $types->unionWith( - $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), + $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), ); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index e09834c78c8..758970b9679 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -109,4 +110,22 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR return $this->typeSpecifier->create($subject, $type, $context, $s); } + /** + * The inside-out create() for a raw subject: narrows it through its own stored + * result's createTypesCallback, falling back to create() when there is none. + * Same signature as TypeSpecifier::create() so call sites swap mechanically. + */ + public function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes + { + $mutatingScope = $scope->toMutatingScope(); + + return $this->createSubjectTypes( + $mutatingScope, + $subject, + $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), + $type, + $context, + ); + } + } diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 9439d8a2c28..510fc4ea706 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -69,25 +69,6 @@ public function __construct( { } - /** - * The inside-out create(): narrows the subject through its own stored result's - * createTypesCallback (so an assignment / remembered wrapper / call / nullsafe - * fans out inside-out), falling back to the raw TypeSpecifier::create() for a - * synthetic subject that has no stored result. Same signature as create() so - * the operand call sites stay uniform. - */ - private function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes - { - $mutatingScope = $scope->toMutatingScope(); - - return $this->defaultNarrowingHelper->createSubjectTypes( - $mutatingScope, - $subject, - $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), - $type, - $context, - ); - } public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { @@ -106,7 +87,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), new ConstantArrayType([], []), ]; - return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === false) { @@ -146,7 +127,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType('0'), ]; } - return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === '') { @@ -168,7 +149,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), ]; } - return $this->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); } if ( @@ -237,7 +218,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() ) { - return $this->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ( @@ -245,7 +226,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() ) { - return $this->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ( @@ -265,8 +246,8 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } } - $leftTypes = $this->createForSubject($expr->left, $leftType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->createForSubject($expr->right, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($expr->left, $leftType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($expr->right, $rightType, $context, $scope)->setRootExpr($expr); return $context->true() ? $leftTypes->unionWith($rightTypes) @@ -359,21 +340,21 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && !$rightType->isConstantScalarValue()->yes() && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) ) { - $arrayTypes = $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + $arrayTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); return $arrayTypes->unionWith( - $this->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), ); } } if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if ($context->truthy() && !$argType->isArray()->yes()) { $newArgType = new UnionType([ @@ -385,24 +366,24 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } return $funcTypes->unionWith( - $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), ); } $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); if ($specifiedTypes !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); return $specifiedTypes->unionWith($funcTypes); } return $specifiedTypes; } if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { return $funcTypes->unionWith( - $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), ); } @@ -421,27 +402,27 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $rightType->isInteger()->yes() ) { if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); } $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); return $funcTypes->unionWith( - $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), ); } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { - $funcTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); $accessory = new AccessoryNonEmptyStringType(); if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { $accessory = new AccessoryNonFalsyStringType(); } - $valueTypes = $this->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); + $valueTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); return $funcTypes->unionWith($valueTypes); } @@ -466,10 +447,10 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $argType = $getType($args[0]->value); if ($argType->isArray()->yes()) { if ($bothDirections) { - return $this->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } if ($context->falsey()) { - return $this->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); } } } @@ -501,20 +482,20 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope ) { $constantStringTypes = $rightType->getConstantStrings(); if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, $rightType->getClassStringObjectType(), $context, $scope, - )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } } @@ -536,7 +517,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($argType->isString()->yes()) { $specifiedTypes = new SpecifiedTypes(); if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { - $specifiedTypes = $this->createForSubject( + $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), $context, @@ -544,7 +525,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope )->setRootExpr($expr); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $specifiedTypes = $this->createForSubject( + $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), $context, @@ -553,7 +534,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($rightType->isNonFalsyString()->yes()) { - return $specifiedTypes->unionWith($this->createForSubject( + return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), $context, @@ -561,7 +542,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope )->setRootExpr($expr)); } - return $specifiedTypes->unionWith($this->createForSubject( + return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), $context, @@ -588,7 +569,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($types !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); } return $types; } @@ -608,7 +589,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($specifiedType !== null) { if ($exprNode !== $unwrappedExprNode) { $specifiedType = $specifiedType->unionWith( - $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($expr), ); } return $specifiedType; @@ -627,12 +608,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $constantStrings = $rightType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->class, new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } return $this->typeSpecifier->specifyTypesInCondition( $scope, @@ -641,7 +622,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } } @@ -659,12 +640,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $constantStrings = $leftType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr->class, new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); } return $this->typeSpecifier->specifyTypesInCondition( @@ -674,7 +655,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); } } @@ -684,14 +665,14 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $never = new NeverType(); $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $this->createForSubject($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } else { - $leftTypes = $this->createForSubject($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $this->createForSubject($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } else { - $rightTypes = $this->createForSubject($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } return $leftTypes->unionWith($rightTypes); } @@ -706,14 +687,14 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && !$rightType->equals($leftType) && $rightType->isSuperTypeOf($leftType)->yes()) ) { - $types = $this->createForSubject( + $types = $this->defaultNarrowingHelper->createForSubject( $rightExpr, $leftType, $context, $scope, )->setRootExpr($expr); if ($rightExpr instanceof AlwaysRememberedExpr) { - $types = $types->unionWith($this->createForSubject( + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr, $leftType, $context, @@ -730,7 +711,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $leftType->isSuperTypeOf($rightType)->yes() ) ) { - $leftTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); if ($types !== null) { $types = $types->unionWith($leftTypes); } else { @@ -751,12 +732,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($context->true()) { - $leftTypes = $this->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->createForSubject($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) - ->intersectWith($this->createForSubject($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); + return $this->defaultNarrowingHelper->createForSubject($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); @@ -806,7 +787,7 @@ private function specifyTypesForConstantBinaryExpression( ): ?SpecifiedTypes { if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } @@ -819,7 +800,7 @@ private function specifyTypesForConstantBinaryExpression( } if (!$context->null() && $constantType->isTrue()->yes()) { - $types = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } @@ -883,8 +864,8 @@ private function specifyTypesForConstantStringBinaryExpression( } if ($type !== null) { - $callType = $this->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - $argType = $this->createForSubject($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); + $callType = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $argType = $this->defaultNarrowingHelper->createForSubject($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); return $callType->unionWith($argType); } } @@ -904,7 +885,7 @@ private function specifyTypesForConstantStringBinaryExpression( $classStringType = new GenericClassStringType($objectType); if ($argType->isString()->yes()) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, $classStringType, $context, @@ -913,7 +894,7 @@ private function specifyTypesForConstantStringBinaryExpression( } if ($argType->isObject()->yes()) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, $objectType, $context, @@ -921,7 +902,7 @@ private function specifyTypesForConstantStringBinaryExpression( )->setRootExpr($rootExpr); } - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, TypeCombinator::union($objectType, $classStringType), $context, @@ -946,7 +927,7 @@ private function specifyTypesForConstantStringBinaryExpression( // instead of re-walking via Scope::getType(). $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($argValue, $scope->toMutatingScope()); if ($argType->isString()->yes()) { - return $this->createForSubject( + return $this->defaultNarrowingHelper->createForSubject( $argValue, new IntersectionType([ new StringType(), diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 40b9b5466e8..3e93418a530 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -63,6 +64,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -215,7 +217,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $type = $readType($issetExpr); $isNullable = !$type->isNull()->no(); - $exprType = $this->typeSpecifier->create( + $exprType = $this->defaultNarrowingHelper->createForSubject( $issetExpr, new NullType(), $context->negate(), @@ -229,7 +231,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // variable cannot exist in !isset() - return $exprType->unionWith($this->typeSpecifier->create( + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( new IssetExpr($issetExpr), new NullType(), $context, @@ -239,7 +241,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($isNullable) { // reduces variable certainty to maybe - return $exprType->unionWith($this->typeSpecifier->create( + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( new IssetExpr($issetExpr), new NullType(), $context->negate(), @@ -248,7 +250,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // variable cannot exist in !isset() - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( new IssetExpr($issetExpr), new NullType(), $context, @@ -283,7 +285,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($typesToRemove !== []) { $typeToRemove = TypeCombinator::union(...$typesToRemove); - $result = $this->typeSpecifier->create( + $result = $this->defaultNarrowingHelper->createForSubject( $issetExpr->var, $typeToRemove, TypeSpecifierContext::createFalse(), @@ -292,7 +294,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($s->hasExpressionType($issetExpr->var)->maybe()) { $result = $result->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( new IssetExpr($issetExpr->var), new NullType(), TypeSpecifierContext::createTruthy(), @@ -347,7 +349,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { $types = $types->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $var->var, new HasOffsetType($dimType), $context, @@ -360,7 +362,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); if ($narrowedKey !== null) { $types = $types->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $var->dim, $narrowedKey, $context, @@ -371,7 +373,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($varType->isArray()->yes()) { $types = $types->unionWith( - $this->typeSpecifier->create( + $this->defaultNarrowingHelper->createForSubject( $var->var, new NonEmptyArrayType(), $context, @@ -387,7 +389,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $var->name instanceof Identifier ) { $types = $types->unionWith( - $this->typeSpecifier->create($var->var, new IntersectionType([ + $this->defaultNarrowingHelper->createForSubject($var->var, new IntersectionType([ new ObjectWithoutClassType(), new HasPropertyType($var->name->toString()), ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), @@ -398,7 +400,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $var->name instanceof VarLikeIdentifier ) { $types = $types->unionWith( - $this->typeSpecifier->create($var->class, new IntersectionType([ + $this->defaultNarrowingHelper->createForSubject($var->class, new IntersectionType([ new ObjectWithoutClassType(), new HasPropertyType($var->name->toString()), ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), @@ -406,7 +408,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $types = $types->unionWith( - $this->typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($expr), ); } From 9c68a16698eb94bdf23aeeb1ea7b27385b6d021e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:17:06 +0200 Subject: [PATCH 138/378] Resolve child narrowing on demand instead of via specifyTypesInCondition getChildSpecifiedTypes' fallback (a child with no wired specifyTypesCallback, or a synthetic node with no result) now processes the child on demand and asks its result for the narrowing - $s->specifyTypesOfNewWorldHandlerNode(...) ?? default - the same path specifyTypesInCondition already routes handler-supported nodes through, minus the old-world dispatcher. The create-side fallback deliberately stays on create(): its handlers' createTypesCallbacks self-reference their own node, so on-demand routing there would re-enter infinitely. --- .../Helper/DefaultNarrowingHelper.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 758970b9679..63e1dfe8cb6 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -37,11 +37,10 @@ public function __construct( /** * The narrowing of an already-processed child expression in the given * boolean context: answered by the child result's specifyTypesCallback. - * Until the child's handler migrates its narrowing - or when the child - * is a synthetic node with no result - this bridges through the - * old-world dispatcher, which answers converted handlers from stored - * results, so the bridge terminates. The bridge dies in 3.0 together - * with TypeSpecifier::specifyTypesInCondition(). + * When the child wired no callback, or is a synthetic node with no result, + * it is processed on demand and asked for its narrowing - the same path + * TypeSpecifier::specifyTypesInCondition() routes handler-supported nodes + * through, but without the old-world dispatcher. */ public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?ExpressionResult $childResult, TypeSpecifierContext $context): SpecifiedTypes { @@ -52,7 +51,12 @@ public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?Expre } } - return $this->typeSpecifier->specifyTypesInCondition($s, $childExpr, $context); + if ($childExpr instanceof Expr\CallLike && $childExpr->isFirstClassCallable()) { + return (new SpecifiedTypes([], []))->setRootExpr($childExpr); + } + + return $s->specifyTypesOfNewWorldHandlerNode($childExpr, $context) + ?? $this->specifyDefaultTypes($childExpr, $context); } public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes From 0bf9be6856d01871d8dc13b41802f0742c46c5b1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:21:56 +0200 Subject: [PATCH 139/378] Resolve BinaryOpHandler re-dispatched conditions on demand Extract the on-demand narrowing of getChildSpecifiedTypes into a reusable DefaultNarrowingHelper::specifyTypesForNode(scope, node, context), and route BinaryOpHandler's synthetic re-dispatches (NotIdentical->Identical, NotEqual->Equal, inverse comparisons, count===) through it instead of the old-world specifyTypesInCondition. Each synthetic is a different node type than the one being processed, so on-demand processing cannot self-cycle. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 12 +++++------ .../Helper/DefaultNarrowingHelper.php | 20 +++++++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 1e49ca43a08..0a680638856 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -259,7 +259,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new BinaryOp\Identical($expr->left, $expr->right), $context->negate(), @@ -276,7 +276,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new BinaryOp\Equal($expr->left, $expr->right), $context->negate(), @@ -308,7 +308,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, $inverseOperator, $context->negate(), @@ -480,7 +480,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // 0 < preg_match or 1 <= preg_match becomes 1 === preg_match $newExpr = new BinaryOp\Identical($expr->right, new Scalar\Int_(1)); - return $this->typeSpecifier->specifyTypesInCondition($scope, $newExpr, $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, $newExpr, $context)->setRootExpr($expr); } if ( @@ -608,11 +608,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\Greater) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); } if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); } return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 63e1dfe8cb6..40aca374a32 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -51,12 +51,24 @@ public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?Expre } } - if ($childExpr instanceof Expr\CallLike && $childExpr->isFirstClassCallable()) { - return (new SpecifiedTypes([], []))->setRootExpr($childExpr); + return $this->specifyTypesForNode($s, $childExpr, $context); + } + + /** + * Narrows an arbitrary (often synthetic) node in the given boolean context by + * processing it on demand and asking its result, the inside-out replacement + * for TypeSpecifier::specifyTypesInCondition() on the handler path. A node not + * stored is processed on demand; a node whose handler wired no specifyTypesCallback + * (or no handler) yields the default truthy/falsey narrowing. + */ + public function specifyTypesForNode(Scope $scope, Expr $node, TypeSpecifierContext $context): SpecifiedTypes + { + if ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + return (new SpecifiedTypes([], []))->setRootExpr($node); } - return $s->specifyTypesOfNewWorldHandlerNode($childExpr, $context) - ?? $this->specifyDefaultTypes($childExpr, $context); + return $scope->toMutatingScope()->specifyTypesOfNewWorldHandlerNode($node, $context) + ?? $this->specifyDefaultTypes($node, $context); } public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes From 618b252b599209716f41f30abcf5f4f5f3d2b571 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:24:53 +0200 Subject: [PATCH 140/378] Resolve equality re-dispatched conditions on demand Route EqualityTypeSpecifyingHelper's re-dispatched conditions (==null/true/false, Equal->Identical, ->Instanceof_, preg_match/operand dispatch) through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. specifyTypesInCondition already funnelled these synthetic nodes through specifyTypesOfNewWorldHandlerNode/processExprOnDemand, so the path and its termination are unchanged; none rebuilds an Identical with the same operands, so there is no self-cycle. --- .../Helper/EqualityTypeSpecifyingHelper.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 510fc4ea706..d865af8a640 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -91,7 +91,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } if (!$context->null() && $constantType->getValue() === false) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, $exprNode, $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), @@ -99,7 +99,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } if (!$context->null() && $constantType->getValue() === true) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, $exprNode, $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), @@ -160,7 +160,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && isset($exprNode->getArgs()[0]) && $constantType->isString()->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } if ( @@ -170,7 +170,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $exprNode->name->toLowerString() === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } if ( @@ -180,7 +180,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && strtolower($exprNode->name->toString()) === 'class' && $constantType->isString()->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } } @@ -191,7 +191,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ $leftBooleanType = $leftType->toBoolean(); if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Expr\BinaryOp\Identical( new ConstFetch(new Name($leftBooleanType->getValue() ? 'true' : 'false')), @@ -203,7 +203,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ $rightBooleanType = $rightType->toBoolean(); if ($rightBooleanType instanceof ConstantBooleanType && $leftType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Expr\BinaryOp\Identical( $expr->left, @@ -235,7 +235,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } $leftExprString = $this->exprPrinter->printExpr($expr->left); @@ -464,7 +464,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, $leftExpr, $context, @@ -615,7 +615,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $scope, )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Instanceof_( $unwrappedLeftExpr->class, @@ -648,7 +648,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Instanceof_( $unwrappedRightExpr->class, @@ -792,7 +792,7 @@ private function specifyTypesForConstantBinaryExpression( return $types; } - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( + return $types->unionWith($this->defaultNarrowingHelper->specifyTypesForNode( $scope, $exprNode, $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), @@ -805,7 +805,7 @@ private function specifyTypesForConstantBinaryExpression( return $types; } - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( + return $types->unionWith($this->defaultNarrowingHelper->specifyTypesForNode( $scope, $exprNode, $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), From 32ad24586726ee536b812e39e987e192f1b78c35 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:27:53 +0200 Subject: [PATCH 141/378] Resolve empty()'s isset-or-falsey condition on demand empty($x) narrowing builds a synthetic `!isset($x) || !$x` BooleanOr; route it through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. Its BooleanNot leaves now resolve through the on-demand getChildSpecifiedTypes path; nothing in that chain re-synthesizes the empty() node, so it terminates. EmptyHandler no longer needs the TypeSpecifier dependency. --- src/Analyser/ExprHandler/EmptyHandler.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index f366c8286f4..775021a2571 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\EmptyExpressionNode; @@ -33,7 +33,7 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -77,7 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new SpecifiedTypes(); } - return $this->typeSpecifier->specifyTypesInCondition($s, new BooleanOr( + return $this->defaultNarrowingHelper->specifyTypesForNode($s, new BooleanOr( new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), new Expr\BooleanNot($expr->expr), ), $context)->setRootExpr($expr); From d96e467970300650c121a91a2db9291d7a18297d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:34:36 +0200 Subject: [PATCH 142/378] Compose negation narrowing from the operand result; isset on demand BooleanNotHandler narrows its operand by composing directly from the operand's own ExpressionResult (getChildSpecifiedTypes with the captured result) instead of re-resolving the node - the natural inside-out flow, since the operand was just processed above. IssetHandler rewrites multi-var isset() into a synthetic BooleanAnd chain of single-var issets (no captured result for the chain) and resolves it on demand via specifyTypesForNode; the single-var path builds no further chain so it terminates. BooleanNotHandler no longer needs TypeSpecifier. --- src/Analyser/ExprHandler/BooleanNotHandler.php | 8 ++++---- src/Analyser/ExprHandler/IssetHandler.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 09521ee26c9..ab2196e7c15 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -14,7 +14,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\BooleanType; @@ -30,7 +29,6 @@ final class BooleanNotHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -66,12 +64,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - return $this->typeSpecifier->specifyTypesInCondition($s, $expr->expr, $context->negate())->setRootExpr($expr); + // The negated operand was processed above; compose its narrowing + // directly from its result rather than re-resolving the node. + return $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context->negate())->setRootExpr($expr); }, ); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 3e93418a530..7ab6c5f515c 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -203,7 +203,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); } - return $this->typeSpecifier->specifyTypesInCondition($s, $andChain, $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($s, $andChain, $context)->setRootExpr($expr); } $issetExpr = $expr->vars[0]; From 7e44e88e45c500af476fc8b630b6e4f2c60f3fb4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 10:57:01 +0200 Subject: [PATCH 143/378] Thread operand results into equality narrowing BinaryOpHandler hands EqualityTypeSpecifyingHelper a $resultFor closure mapping each operand node to the ExpressionResult it already produced. The helper's existing-operand specify dispatches (==false/==true, preg_match ===) now compose the operand's narrowing from that captured result via getChildSpecifiedTypes rather than re-resolving the node through specifyTypesForNode. Behaviour is unchanged - the on-demand path resolved the same stored result - but the common operands are now composed inside-out directly; a non-operand or unwrapped node maps to null and still falls back to on-demand resolution. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 6 ++- .../Helper/EqualityTypeSpecifyingHelper.php | 38 +++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 0a680638856..4bd3e10e4c7 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use Countable; use DivisionByZeroError; use PhpParser\Node\Expr; @@ -246,8 +247,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + $resultFor = static fn (Expr $e): ?ExpressionResult => $e === $expr->left ? $leftResult : ($e === $expr->right ? $rightResult : null); if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context); + return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context, $resultFor); } if ($expr instanceof BinaryOp\NotIdentical) { @@ -267,7 +269,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context); + return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context, $resultFor); } if ($expr instanceof BinaryOp\NotEqual) { diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index d865af8a640..0efe9285b96 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use Closure; use Countable; use PhpParser\Node; use PhpParser\Node\Expr; @@ -10,6 +11,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -70,7 +72,10 @@ public function __construct( } - public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes { $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); if ($expressions !== null) { @@ -91,17 +96,19 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } if (!$context->null() && $constantType->getValue() === false) { - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $scope->toMutatingScope(), $exprNode, + $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), )->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === true) { - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $scope->toMutatingScope(), $exprNode, + $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), )->setRootExpr($expr); } @@ -254,7 +261,10 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ : $leftTypes->normalize($scope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($scope, $nodeScopeResolver)); } - public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes { $leftExpr = $expr->left; $rightExpr = $expr->right; @@ -264,12 +274,12 @@ public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, E $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, - ), $scope, $context); + ), $scope, $context, $resultFor); } else { $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $leftExpr, $rightExpr, - ), $scope, $context); + ), $scope, $context, $resultFor); } // merge result of fn1() === fn2() and fn2() === fn1() @@ -278,14 +288,17 @@ public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, E $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, - ), $scope, $context), + ), $scope, $context, $resultFor), ); } return $specifiedTypes; } - private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes { $leftExpr = $expr->left; $rightExpr = $expr->right; @@ -464,9 +477,10 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() ) { - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, + return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + $scope->toMutatingScope(), $leftExpr, + $resultFor($leftExpr), $context, )->setRootExpr($expr); } From 16004b3c299befcfebccc6be6a9b71c094f79f24 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 11:16:48 +0200 Subject: [PATCH 144/378] Compose remembered-operand narrowing from the result, dropping manual unwraps createForSubject takes an optional $resultFor lookup so a parent handler that already produced the operand's result composes from it directly; an AlwaysRememberedExpr operand then fans out to wrapper + inner through its own createTypesCallback. Thread it through EqualityTypeSpecifyingHelper's operand createForSubject calls (and finish the specify side in specifyTypesForConstantBinaryExpression), then delete the manual "if ($x instanceof AlwaysRememberedExpr) narrow the unwrapped inner" blocks in the never and finite-type branches - the composed path covers them (it also narrows the wrapper key, matching create()'s double-key). The remaining AlwaysRememberedExpr instanceof checks are structural: they unwrap to inspect the inner node's syntax (count/preg_match/get_class/::class), not to narrow. --- .../Helper/DefaultNarrowingHelper.php | 13 +- .../Helper/EqualityTypeSpecifyingHelper.php | 119 +++++++++--------- 2 files changed, 69 insertions(+), 63 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 40aca374a32..b2ebcafd877 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use Closure; use PhpParser\Node\Expr; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; @@ -129,16 +130,22 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR /** * The inside-out create() for a raw subject: narrows it through its own stored * result's createTypesCallback, falling back to create() when there is none. - * Same signature as TypeSpecifier::create() so call sites swap mechanically. + * When the caller already holds the subject's result (e.g. an operand a parent + * handler just processed) it passes a $resultFor lookup so composition uses that + * captured result directly instead of a storage lookup - so a remembered-wrapper + * operand fans out to wrapper + inner without the caller unwrapping it. + * + * @param (Closure(Expr): ?ExpressionResult)|null $resultFor */ - public function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes + public function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope, ?Closure $resultFor = null): SpecifiedTypes { $mutatingScope = $scope->toMutatingScope(); + $subjectResult = $resultFor !== null ? $resultFor($subject) : null; return $this->createSubjectTypes( $mutatingScope, $subject, - $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), + $subjectResult ?? $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), $type, $context, ); diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 0efe9285b96..62aa862bcd6 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -92,7 +92,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), new ConstantArrayType([], []), ]; - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === false) { @@ -134,7 +134,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType('0'), ]; } - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === '') { @@ -156,7 +156,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ new ConstantStringType(''), ]; } - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if ( @@ -225,7 +225,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() ) { - return $this->defaultNarrowingHelper->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ( @@ -233,7 +233,7 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ && $leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() ) { - return $this->defaultNarrowingHelper->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ( @@ -253,8 +253,8 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } } - $leftTypes = $this->defaultNarrowingHelper->createForSubject($expr->left, $leftType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createForSubject($expr->right, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($expr->left, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($expr->right, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $context->true() ? $leftTypes->unionWith($rightTypes) @@ -353,21 +353,21 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && !$rightType->isConstantScalarValue()->yes() && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) ) { - $arrayTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); + $arrayTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr); return $arrayTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), ); } } if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); } $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); if ($context->truthy() && !$argType->isArray()->yes()) { $newArgType = new UnionType([ @@ -379,24 +379,24 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope, $resultFor)->setRootExpr($expr), ); } $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); if ($specifiedTypes !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $specifiedTypes->unionWith($funcTypes); } return $specifiedTypes; } if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), ); } @@ -415,27 +415,27 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $rightType->isInteger()->yes() ) { if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); } $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope, $resultFor)->setRootExpr($expr), ); } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); $accessory = new AccessoryNonEmptyStringType(); if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { $accessory = new AccessoryNonFalsyStringType(); } - $valueTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); + $valueTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope, $resultFor)->setRootExpr($expr); return $funcTypes->unionWith($valueTypes); } @@ -460,10 +460,10 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $argType = $getType($args[0]->value); if ($argType->isArray()->yes()) { if ($bothDirections) { - return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ($context->falsey()) { - return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } } } @@ -501,7 +501,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } if ($rightType->getClassStringObjectType()->isObject()->yes()) { return $this->defaultNarrowingHelper->createForSubject( @@ -509,7 +510,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $rightType->getClassStringObjectType(), $context, $scope, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } } @@ -536,6 +538,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { @@ -544,6 +547,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr); } @@ -553,6 +557,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr)); } @@ -561,6 +566,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr)); } } @@ -583,7 +589,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($types !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)); } return $types; } @@ -599,11 +605,11 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $unwrappedExprNode = $exprNode->getExpr(); } - $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr); + $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr, $resultFor); if ($specifiedType !== null) { if ($exprNode !== $unwrappedExprNode) { $specifiedType = $specifiedType->unionWith( - $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($expr), ); } return $specifiedType; @@ -627,7 +633,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, @@ -636,7 +643,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } } @@ -659,7 +666,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), $context, $scope, - )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); } return $this->defaultNarrowingHelper->specifyTypesForNode( @@ -669,7 +677,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); } } @@ -678,16 +686,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope if ($identicalType instanceof ConstantBooleanType) { $never = new NeverType(); $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } else { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } else { - $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } } @@ -706,15 +706,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $leftType, $context, $scope, + $resultFor, )->setRootExpr($expr); - if ($rightExpr instanceof AlwaysRememberedExpr) { - $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject( - $unwrappedRightExpr, - $leftType, - $context, - $scope, - ))->setRootExpr($expr); - } } if ( count($rightType->getFiniteTypes()) === 1 @@ -725,7 +718,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $leftType->isSuperTypeOf($rightType)->yes() ) ) { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); if ($types !== null) { $types = $types->unionWith($leftTypes); } else { @@ -746,12 +739,12 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope } if ($context->true()) { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->defaultNarrowingHelper->createForSubject($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) - ->intersectWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); + return $this->defaultNarrowingHelper->createForSubject($leftExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); @@ -792,36 +785,42 @@ private function findTypeExpressionsFromBinaryOperation(NodeScopeResolver $nodeS return null; } + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ private function specifyTypesForConstantBinaryExpression( Expr $exprNode, Type $constantType, TypeSpecifierContext $context, Scope $scope, Expr $rootExpr, + Closure $resultFor, ): ?SpecifiedTypes { if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } - return $types->unionWith($this->defaultNarrowingHelper->specifyTypesForNode( - $scope, + return $types->unionWith($this->defaultNarrowingHelper->getChildSpecifiedTypes( + $scope->toMutatingScope(), $exprNode, + $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), )->setRootExpr($rootExpr)); } if (!$context->null() && $constantType->isTrue()->yes()) { - $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } - return $types->unionWith($this->defaultNarrowingHelper->specifyTypesForNode( - $scope, + return $types->unionWith($this->defaultNarrowingHelper->getChildSpecifiedTypes( + $scope->toMutatingScope(), $exprNode, + $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), )->setRootExpr($rootExpr)); } From e9f98400f9d5396db3ce1bc75692ff7098f23b91 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 11:47:37 +0200 Subject: [PATCH 145/378] Drop the AlwaysRememberedExpr fan-out from TypeSpecifier::create Now that operand narrowing composes from the operand's own result (which fans a remembered wrapper out to wrapper + inner through AlwaysRememberedExprHandler), create() no longer needs to special-case AlwaysRememberedExpr. The only raw caller passing one is the class_exists/enum_exists extension, and it wraps a synthetic class_exists('Foo') call whose inner is meaningless - only the remembered wrapper key matters - so narrowing the unwrapped inner was a no-op. The whole suite stays green with the branch gone. --- src/Analyser/TypeSpecifier.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d57f3792803..6c7b179b186 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -13,7 +13,6 @@ use PhpParser\Node\Name; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; -use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Reflection\Assertions; @@ -512,11 +511,6 @@ public function create( } $specifiedExprs = []; - if ($expr instanceof AlwaysRememberedExpr) { - $specifiedExprs[] = $expr; - $expr = $expr->expr; - } - if ($expr instanceof Expr\Assign) { $specifiedExprs[] = $expr->var; $specifiedExprs[] = $expr->expr; From 8fcdf21277e01975aa113261b1c32cafb91f4b76 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 12:03:55 +0200 Subject: [PATCH 146/378] Resolve conditional-after-assign narrowing on demand Route AssignHandler's conditional-expression-after-assign narrowing (the ternary condition, the assigned expression, and the synthetic !==/=== falsey/truthy conditions) through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. The synthetic conditions are built over the already-stored assigned expression and resolve to a different node type, so on-demand processing cannot self-cycle. AssignHandler no longer needs the TypeSpecifier dependency. --- src/Analyser/ExprHandler/AssignHandler.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index c18e89b1b0f..fab496496e2 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -37,7 +37,6 @@ use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; @@ -93,7 +92,6 @@ final class AssignHandler implements ExprHandler { public function __construct( - private TypeSpecifier $typeSpecifier, private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, @@ -472,8 +470,8 @@ public function processAssignVar( $if = $assignedExpr->cond; } $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); $truthyType = $nodeScopeResolver->readStoredOrPriceOnDemand($if, $truthyScope); @@ -504,12 +502,12 @@ public function processAssignVar( // a fast path (equals() has no such shortcut, and no-op removal is the common // case here). if ($truthyType !== $type && !$truthyType->equals($type)) { - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } @@ -539,12 +537,12 @@ public function processAssignVar( } $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); + $notIdenticalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); + $identicalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } From b4eb6b47f52e7417094b65db2ec01813bf863b3a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 12:29:04 +0200 Subject: [PATCH 147/378] Resolve nullsafe receiver-and-fetch condition on demand The nullsafe `?->` narrowing builds a synthetic `$var !== null && $plainFetch` BooleanAnd; route it through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world specifyTypesInCondition. The embedded plain fetch was already processed in processExpr so it has a stored result - on-demand processing reads it rather than re-synthesizing the nullsafe chain, so there is no re-entry. The remaining handleDefaultTruthyOrFalseyContext call is a separate bridge. --- src/Analyser/ExprHandler/NullsafeMethodCallHandler.php | 2 +- src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 88e47333027..df8aa5fa305 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -124,7 +124,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - $types = $this->typeSpecifier->specifyTypesInCondition( + $types = $this->defaultNarrowingHelper->specifyTypesForNode( $s, new BooleanAnd( new NotIdentical($expr->var, new ConstFetch(new Name('null'))), diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 3e2d75746b5..4fd09440a6f 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -104,7 +104,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - $types = $this->typeSpecifier->specifyTypesInCondition( + $types = $this->defaultNarrowingHelper->specifyTypesForNode( $s, new BooleanAnd( new NotIdentical($expr->var, new ConstFetch(new Name('null'))), From d7e292f5f59d58fd6eb4f229b45ba001682871e5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 12:35:34 +0200 Subject: [PATCH 148/378] Read expression-statement narrowing from its result The expression statement was just processed into $result; read its null-context narrowing from $result->getSpecifiedTypesForScope() instead of re-resolving the same expression through specifyTypesInCondition(). --- src/Analyser/NodeScopeResolver.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 52980225fd8..a9a69ffe9b2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1245,11 +1245,12 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( - $scope, - $stmt->expr, - TypeSpecifierContext::createNull(), - )); + // the expression statement was just processed; read its narrowing from + // the result instead of re-resolving it via specifyTypesInCondition(). + $specifiedTypes = $result->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createNull()); + if ($specifiedTypes !== null) { + $scope = $scope->filterBySpecifiedTypes($specifiedTypes); + } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); From 78b5dad049610efe30f07fd590a189ff7cc49f93 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 12:35:34 +0200 Subject: [PATCH 149/378] Resolve the impossible-check condition through the scope's dispatcher ImpossibleCheckTypeHelper narrows the checked condition through the scope's on-demand dispatcher (which reads the condition's already-computed result) plus a default fallback, instead of the old-world specifyTypesInCondition(). --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 13f62f78cb6..84087cbc11c 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -315,7 +315,11 @@ private function getSpecifiedType( } $typeSpecifierScope = $this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(); - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($typeSpecifierScope, $node, $this->determineContext($typeSpecifierScope, $node)); + $typeSpecifierContext = $this->determineContext($typeSpecifierScope, $node); + // the condition expression was already analysed; read its narrowing from its + // result (via the scope's on-demand dispatcher) instead of specifyTypesInCondition(). + $specifiedTypes = $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext) + ?? $this->typeSpecifier->specifyDefaultTypes($typeSpecifierScope, $node, $typeSpecifierContext); // don't validate types on overwrite if ($specifiedTypes->shouldOverwrite()) { From f08846eceb1c1a958a4c4dbdb8f53efa4e888e9d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 13:31:47 +0200 Subject: [PATCH 150/378] Apply the expression-statement narrowing via applySpecifiedTypes Use the new-world applySpecifiedTypes (certainty-aware, tracked-type) instead of the old-world filterBySpecifiedTypes when applying the expression statement's null-context narrowing read from its result. --- src/Analyser/NodeScopeResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a9a69ffe9b2..e8cbbc6f223 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1249,7 +1249,7 @@ public function processStmtNode( // the result instead of re-resolving it via specifyTypesInCondition(). $specifiedTypes = $result->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createNull()); if ($specifiedTypes !== null) { - $scope = $scope->filterBySpecifiedTypes($specifiedTypes); + $scope = $scope->applySpecifiedTypes($specifiedTypes); } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); From 99241d926808ff8d54bf64c11e13a36e3a382c56 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 13:34:02 +0200 Subject: [PATCH 151/378] Apply do-while exit narrowing from the condition result Replace filterByFalseyValue($stmt->cond) on the post-loop scope with the loop condition's own result narrowing that scope (getSpecifiedTypesForScope + the new-world applySpecifiedTypes) - the inside-out form, since the condition was already processed into $bodyCondResult. --- src/Analyser/NodeScopeResolver.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e8cbbc6f223..532d9550f35 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1866,7 +1866,13 @@ public function processStmtNode( $bodyCondResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); $bodyScope = $bodyCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + $finalScope = $finalScopeResult->getScope(); + // the loop condition's own result narrows the post-loop scope to its + // falsey branch, applied via the new-world applySpecifiedTypes. + $condFalsey = $bodyCondResult->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey()); + if ($condFalsey !== null) { + $finalScope = $finalScope->applySpecifiedTypes($condFalsey); + } $alwaysIterates = false; $neverIterates = false; From 83064b50fedcb971dc54435e53438126bb2a5f4d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 13:40:16 +0200 Subject: [PATCH 152/378] Narrow control-flow scopes via applySpecifiedTypes, not filterBy*Value Replace the remaining old-world filterByTruthyValue/filterByFalseyValue calls in foreach, do-while loop body, if/elseif and match handling with a narrowScopeWithCondition helper that resolves the (synthetic) condition through the scope's on-demand dispatcher and applies it via the new-world applySpecifiedTypes. NodeScopeResolver no longer uses the old-world filters. --- src/Analyser/NodeScopeResolver.php | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 532d9550f35..08cc45d4798 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -441,6 +441,19 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void * @param Node\Stmt[] $bodyStmts * @param Closure(string): bool $gotoNameMatcher */ + /** + * Narrows a scope by a (often synthetic) control-flow condition the new-world + * way: resolve its narrowing through the scope's on-demand dispatcher and apply + * it via applySpecifiedTypes, instead of the old-world filterBy*Value(). + */ + private function narrowScopeWithCondition(MutatingScope $scope, Expr $expr, TypeSpecifierContext $context): MutatingScope + { + $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context) + ?? $this->typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + + return $scope->applySpecifiedTypes($specifiedTypes); + } + private function resolveBackwardGotoScope( Node $parentNode, array $bodyStmts, @@ -1597,7 +1610,7 @@ public function processStmtNode( if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; + $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope; $foreachIterateeType = $condResult->getTypeForScope($originalScope); $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context, $foreachIterateeType, $foreachNativeIterateeType); @@ -1610,7 +1623,7 @@ public function processStmtNode( $count = 0; do { $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); $storage = $originalStorage->duplicate(); $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); @@ -1630,7 +1643,7 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; @@ -1779,7 +1792,7 @@ public function processStmtNode( $isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce(); if ($isIterableAtLeastOnce->maybe() || $exprType->isIterable()->no()) { - $finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr( + $finalScope = $finalScope->mergeWith($this->narrowScopeWithCondition($scope, new BooleanOr( new BinaryOp\Identical( $stmt->expr, new Array_([]), @@ -1787,7 +1800,7 @@ public function processStmtNode( new FuncCall(new Name\FullyQualified('is_object'), [ new Arg($stmt->expr), ]), - ))); + ), TypeSpecifierContext::createTruthy())); } elseif ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) { $finalScope = $scope; } elseif (!$this->polluteScopeWithAlwaysIterableForeach) { @@ -2114,7 +2127,7 @@ public function processStmtNode( $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { - $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + $finalScope = $this->narrowScopeWithCondition($finalScope, $lastCondExpr, TypeSpecifierContext::createFalsey()); } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); @@ -2183,7 +2196,7 @@ public function processStmtNode( $hasYield = $hasYield || $caseResult->hasYield(); $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints()); - $branchScope = $caseResult->getScope()->filterByTruthyValue($condExpr); + $branchScope = $this->narrowScopeWithCondition($caseResult->getScope(), $condExpr, TypeSpecifierContext::createTruthy()); } else { $hasDefaultCase = true; $fullCondExpr = null; @@ -2209,7 +2222,7 @@ public function processStmtNode( $alwaysTerminating = $alwaysTerminating && $branchFinalScopeResult->isAlwaysTerminating(); $prevScope = null; if (isset($fullCondExpr)) { - $scopeForBranches = $scopeForBranches->filterByFalseyValue($fullCondExpr); + $scopeForBranches = $this->narrowScopeWithCondition($scopeForBranches, $fullCondExpr, TypeSpecifierContext::createFalsey()); $fullCondExpr = null; } if (!$branchFinalScopeResult->isAlwaysTerminating()) { From b8388ca54fc8dcc92dd3808f732ae3e327a877db Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 14:47:41 +0200 Subject: [PATCH 153/378] Require typeCallback and specifyTypesCallback on ExpressionResult Make both callbacks required (nullable but required) on the ExpressionResult constructor and factory, and validate the type combination: a typeCallback and a precomputed type are mutually exclusive, and the phpdoc type and native type are set together or not at all. This surfaces every result that did not explicitly decide how it propagates its type / narrowing. Resolve all 23 surfaced call sites: results that genuinely do not narrow get an empty specifyTypesCallback; results that precompute an eager type pass typeCallback: null. Eight results that previously fell back to Scope::getType() (Assign / AssignOp / implicit to-string / a few NodeScopeResolver results) get a MixedType typeCallback as a deliberate stopgap - the whole suite stays green, which shows their types are not yet covered; propagating their real type inside-out is follow-up work. --- src/Analyser/ExprHandler/ArrayHandler.php | 2 ++ .../ExprHandler/ArrowFunctionHandler.php | 1 + src/Analyser/ExprHandler/AssignHandler.php | 11 +++++++++-- src/Analyser/ExprHandler/AssignOpHandler.php | 3 +++ src/Analyser/ExprHandler/ClosureHandler.php | 1 + .../Helper/ImplicitToStringCallHelper.php | 6 ++++++ src/Analyser/ExprHandler/PipeHandler.php | 1 + src/Analyser/ExprHandler/ScalarHandler.php | 2 ++ .../Virtual/ExistingArrayDimFetchHandler.php | 2 ++ .../SetExistingOffsetValueTypeExprHandler.php | 2 ++ .../Virtual/SetOffsetValueTypeExprHandler.php | 2 ++ .../Virtual/UnsetOffsetExprHandler.php | 2 ++ src/Analyser/ExpressionResult.php | 14 ++++++++++++-- src/Analyser/ExpressionResultFactory.php | 4 ++-- src/Analyser/NodeScopeResolver.php | 18 +++++++++++++++--- 15 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a0550ffad21..efd1ad3a028 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; @@ -124,6 +125,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $type; }, + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 5293eb4ef45..aac27203ae9 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -81,6 +81,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), type: $type, nativeType: $nativeType, + typeCallback: null, ); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index fab496496e2..c5c59da589c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -170,6 +170,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static fn ($scope) => $result->getTypeForScope($scope), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); }, true, @@ -637,6 +638,7 @@ public function processAssignVar( throwPoints: [], impurePoints: [], typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } else { @@ -655,6 +657,7 @@ public function processAssignVar( throwPoints: [], impurePoints: [], typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $s)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $s)), + specifyTypesCallback: static fn () => new SpecifiedTypes(), )); $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $result->hasYield(); @@ -1004,7 +1007,9 @@ public function processAssignVar( $getOffsetValueTypeExpr, $nodeCallback, $context, - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -1102,7 +1107,9 @@ public function processAssignVar( } // stored where processAssignVar is called - return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),); } private function createArrayDimFetchConditionalExpressionHolder( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index f6f498c1317..17afbee30e5 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\ExprHandler; use DivisionByZeroError; +use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; use PhpParser\Node\Expr\BinaryOp; @@ -176,6 +177,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $isAlwaysTerminating, $exprResult->getThrowPoints(), $exprResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 4aceca3957f..591fcc32e2a 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -77,6 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), type: $type, nativeType: $nativeType, + typeCallback: null, ); } diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 5a2a1ee138b..cf79506826b 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -2,6 +2,8 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Identifier; use PHPStan\Analyser\ExpressionContext; @@ -55,6 +57,8 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } @@ -93,6 +97,8 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver isAlwaysTerminating: false, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 54073bc043b..e644d0522c5 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -90,6 +90,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (MutatingScope $s): Type => $callableNodeResult->getTypeForScope($s), + specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 690ec8b8dd6..1349862b63d 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Scalar; use PhpParser\Node\Scalar\InterpolatedString; @@ -49,6 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: fn (Scope $scope) => $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($scope)), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 5645642ccfc..c9866404067 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -50,6 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (MutatingScope $s): Type => $arrayDimFetchResult->getTypeForScope($s), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 59ed410e1c9..b3e85e18b17 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -55,6 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $dimResult->getTypeForScope($s), $valueResult->getTypeForScope($s), ), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 1f1ca8e113f..9cddcbfd431 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -56,6 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $dimResult !== null ? $dimResult->getTypeForScope($s) : null, $valueResult->getTypeForScope($s), ), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index c81be133e7b..09090aac1cd 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -51,6 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->unsetOffset($dimResult->getTypeForScope($s)), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index ec29eb2ce0f..415ce2590e8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use PHPStan\Type\TypeUtils; @@ -53,17 +54,26 @@ public function __construct( private bool $isAlwaysTerminating, private array $throwPoints, private array $impurePoints, + ?callable $typeCallback, + ?callable $specifyTypesCallback, private bool $containsNullsafe = false, private ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, - ?callable $typeCallback = null, - ?callable $specifyTypesCallback = null, ?callable $createTypesCallback = null, private ?Type $type = null, private ?Type $nativeType = null, ) { + // A precomputed type and a lazy typeCallback are mutually exclusive; phpdoc + // and native types are precomputed together or not at all. + if ($typeCallback !== null && $type !== null) { + throw new ShouldNotHappenException('ExpressionResult cannot have both a typeCallback and a precomputed type.'); + } + if (($type === null) !== ($nativeType === null)) { + throw new ShouldNotHappenException('ExpressionResult type and nativeType must both be set or both be null.'); + } + $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; $this->typeCallback = $typeCallback; diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index f960181d3ae..94cbb846b5d 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -25,12 +25,12 @@ public function create( bool $isAlwaysTerminating, array $throwPoints, array $impurePoints, + ?callable $typeCallback, + ?callable $specifyTypesCallback, bool $containsNullsafe = false, ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, - ?callable $typeCallback = null, - ?callable $specifyTypesCallback = null, ?callable $createTypesCallback = null, ?Type $type = null, ?Type $nativeType = null, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 08cc45d4798..0a45a30265e 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6,6 +6,7 @@ use Closure; use IteratorAggregate; use Override; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Comment\Doc; use PhpParser\Modifiers; use PhpParser\Node; @@ -3062,6 +3063,7 @@ private function processExprNodeInternal( // the first-class callable closure type lives on the *CallableNode // result; delegate so getType() of the original CallLike answers from it typeCallback: static fn (MutatingScope $s): Type => $newExprResult->getTypeForScope($s), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); $this->storeExpressionResult($storage, $expr, $expressionResult); return $expressionResult; @@ -3584,7 +3586,9 @@ public function processArrowFunctionNode( $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($refinedArrowFunctionType, $expr), $refinedArrowFunctionScope, $storage); return new ProcessArrowFunctionResult( - $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()), + $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), $arrowFunctionScope, $closureTypeThrowPoints, $closureTypeImpurePoints, @@ -4184,6 +4188,8 @@ public function processArgs( $closureResult->getInvalidateExpressions(), ), nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: static fn () => new SpecifiedTypes(), )); $uses = []; @@ -4275,6 +4281,8 @@ public function processArgs( $arrowFunctionResult->getInvalidateExpressions(), ), nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } else { $exprType = $this->readStoredOrPriceOnDemand($arg->value, $scope); @@ -4433,7 +4441,9 @@ public function processArgs( // not storing this, it's scope after processing all args return new ArgsResult( - $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints), + $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), $resolvedAcceptor, ); } @@ -4737,7 +4747,9 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), false, ); } From 8d48d177f3356e10693258742698f4b391d2b29d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 14:53:05 +0200 Subject: [PATCH 154/378] Remove todo --- src/Analyser/ExprHandler/ScalarHandler.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 1349862b63d..fbefe70d320 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -40,7 +40,6 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // TODO $typeSpecifier->specifyDefaultTypes($scope, $expr, $context) OR noop return $this->expressionResultFactory->create( $scope, beforeScope: $scope, From 41aa004cb1acb0bdb78b3e0d014965c581587698 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 15:21:35 +0200 Subject: [PATCH 155/378] Replace handleDefaultTruthyOrFalseyContext with specifyDefaultTypes The default truthy/falsey narrowing in the nullsafe and call handlers no longer goes through the old-world handleDefaultTruthyOrFalseyContext (which fanned out via create() with nullsafe createNullsafeTypes and the call-purity gate); it uses the new-world DefaultNarrowingHelper::specifyDefaultTypes, a single sureNot entry. Inside-out, the nullsafe handling already lives in the nullsafe handlers' own composition and the call-purity gate in their createTypesCallback, so the create() fan-out was redundant. The nullsafe handlers no longer need TypeSpecifier. --- src/Analyser/ExprHandler/FuncCallHandler.php | 2 +- src/Analyser/ExprHandler/MethodCallHandler.php | 2 +- src/Analyser/ExprHandler/NullsafeMethodCallHandler.php | 4 +--- src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php | 4 +--- src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index ac92aedcddf..e6d1c5602b8 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -1063,7 +1063,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index eaac2eac329..1131bfcc212 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -468,7 +468,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index df8aa5fa305..4caac12bab5 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -20,7 +20,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -39,7 +38,6 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -133,7 +131,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $context, )->setRootExpr($expr); - $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 4fd09440a6f..25cb38a0099 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -20,7 +20,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -39,7 +38,6 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -113,7 +111,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $context, )->setRootExpr($expr); - $nullSafeTypes = $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $s); + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); }, // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 90c8efa9ca3..fb0da756671 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -542,7 +542,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } From baef4f18f66c0263619f84c4ad5d5cdbe8be84ad Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 15:34:13 +0200 Subject: [PATCH 156/378] Read operand/receiver types from their results, not Scope::getType Two reaches into Scope::getType where the child's ExpressionResult was already held: MethodCallHandler's @phpstan-self-out native receiver type now reads $varResult->getNativeTypeForScope() instead of $scope->getNativeType($var), and BinaryOpHandler::resolveEqualType takes the already-captured $leftResult/ $rightResult and reads their types instead of readStoredOrPriceOnDemand on the operands. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 13 ++++++------- src/Analyser/ExprHandler/MethodCallHandler.php | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 4bd3e10e4c7..eaa976dd55b 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -150,14 +150,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof BinaryOp\Equal) { - return $this->resolveEqualType($nodeScopeResolver, $scope, $expr); + return $this->resolveEqualType($scope, $expr, $leftResult, $rightResult); } if ($expr instanceof BinaryOp\NotEqual) { // negation of the Equal result - direct computation avoids // synthesizing a BooleanNot node (which would route through // on-demand re-processing once BooleanNot is migrated) - $equalType = $this->resolveEqualType($nodeScopeResolver, $scope, new BinaryOp\Equal($expr->left, $expr->right))->toBoolean(); + $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right), $leftResult, $rightResult)->toBoolean(); if ($equalType->isTrue()->yes()) { return new ConstantBooleanType(false); } @@ -626,7 +626,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * The boolean result of a `==` comparison, including the same-variable * special case. Shared by the Equal and NotEqual type callbacks. */ - private function resolveEqualType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, BinaryOp\Equal $expr): Type + private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr, ExpressionResult $leftResult, ExpressionResult $rightResult): Type { if ( $expr->left instanceof Variable @@ -638,10 +638,9 @@ private function resolveEqualType(NodeScopeResolver $nodeScopeResolver, Mutating return new ConstantBooleanType(true); } - // the operands were processed during processExpr; read their stored - // results instead of re-walking via Scope::getType(). - $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope); - $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope); + // the operands were processed during processExpr; use their results' types. + $leftType = $leftResult->getTypeForScope($scope); + $rightType = $rightResult->getTypeForScope($scope); return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 1131bfcc212..8e3008b01d7 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -261,7 +261,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createCovariant(), ), - $scope->getNativeType($normalizedExpr->var), + $varResult->getNativeTypeForScope($scope), ); } } From 5a7389c67eaaaabc1e7c9ba7a0c378c1c31592db Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 15:51:55 +0200 Subject: [PATCH 157/378] Guard on-demand pricing paths with PHPSTAN_GUARD_NW Extend the new-world diagnostic guard to the on-demand entry points readStoredOrPriceOnDemand(), readStoredOrPriceOnDemandNative() and specifyTypesOfNewWorldHandlerNode(), mirroring the existing guard in MutatingScope::getType(): a real (non-synthetic) AST node reaching an on-demand pricing path before it was processed and stored by processExprNode() now throws. Dormant unless PHPSTAN_GUARD_NW=1. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GCruhb2WX7CFrhbR1nTWWc --- src/Analyser/MutatingScope.php | 12 ++++++++++++ src/Analyser/NodeScopeResolver.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b2fc2d9da1b..c245684b102 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1254,6 +1254,18 @@ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierConte } } + if ( + NodeScopeResolver::$guardNewWorld + && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) + && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) + ) { + throw new ShouldNotHappenException(sprintf( + 'specifyTypesOfNewWorldHandlerNode() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + get_class($node), + $node->getStartLine(), + )); + } + // a synthetic node, or no analysis in progress $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0a45a30265e..7be2bcf54fb 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2953,9 +2953,36 @@ public function readStoredOrPriceOnDemand(Expr $expr, MutatingScope $scope): Typ return $result->getTypeForScope($scope); } + $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); + return $this->priceSyntheticOnDemand($expr, $scope); } + /** + * Fires the PHPSTAN_GUARD_NW diagnostic when a real (non-synthetic) AST node + * reaches an on-demand pricing path without having been processed and stored + * by processExprNode() first. Mirrors the guard in MutatingScope::getType(): + * such a node should be answered from its stored ExpressionResult, never + * re-priced as if it were synthetic. Dormant unless PHPSTAN_GUARD_NW=1. + */ + private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): void + { + if ( + !self::$guardNewWorld + || !isset(self::$guardRealExprIds[spl_object_id($expr)]) + || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) + ) { + return; + } + + throw new ShouldNotHappenException(sprintf( + '%s() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + $caller, + get_class($expr), + $expr->getStartLine(), + )); + } + /** * Prices a synthetic node (one an ExprHandler built itself) on a duplicate of * the storage of the analysis currently in progress, mirroring @@ -2979,6 +3006,8 @@ public function readStoredOrPriceOnDemandNative(Expr $expr, MutatingScope $scope return $result->getNativeTypeForScope($scope); } + $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); + return $this->priceSyntheticOnDemandNative($expr, $scope); } From b3c00eaea15443818745b72db38c66bb134866da Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 16:04:36 +0200 Subject: [PATCH 158/378] Capture per-arg ExpressionResults; read appending-arg types from them processArgs() now collects each argument's ExpressionResult (keyed by the value node's spl_object_id, robust to the named-arg processing order) and ArgsResult exposes them via getArgResult(). FuncCallHandler::getArrayFunctionAppendingType reads the array and appended-value types from those results instead of Scope::getType(). Closure arguments carry no ExpressionResult (ProcessClosureResult has none), so they remain a scope read; every other argument now flows through its already-processed result. --- src/Analyser/ArgsResult.php | 15 +++++++++++++++ src/Analyser/ExprHandler/FuncCallHandler.php | 17 +++++++++++------ src/Analyser/NodeScopeResolver.php | 4 ++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ArgsResult.php b/src/Analyser/ArgsResult.php index 217cccecf5d..b53d7325cd7 100644 --- a/src/Analyser/ArgsResult.php +++ b/src/Analyser/ArgsResult.php @@ -2,7 +2,9 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; use PHPStan\Reflection\ParametersAcceptor; +use function spl_object_id; /** * Result of NodeScopeResolver::processArgs(): the scope/throw/impure state after @@ -15,13 +17,26 @@ final class ArgsResult { + /** + * @param array $argResults keyed by spl_object_id of each argument's value expression + */ public function __construct( private ExpressionResult $expressionResult, private ?ParametersAcceptor $resolvedParametersAcceptor, + private array $argResults = [], ) { } + /** + * The already-processed ExpressionResult of a call argument's value expression, + * so callers read its type via the result instead of re-asking the scope. + */ + public function getArgResult(Expr $argValue): ?ExpressionResult + { + return $this->argResults[spl_object_id($argValue)] ?? null; + } + public function getScope(): MutatingScope { return $this->expressionResult->getScope(); diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index e6d1c5602b8..f774c2123b2 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -14,6 +14,7 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -484,8 +485,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $stmt, $arrayArg, new NativeTypeExpr( - $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs, $normalizedExpr), - $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs, $normalizedExpr, $argsResult), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr, $argsResult), ), $nodeCallback, )->getScope(); @@ -740,19 +741,23 @@ private function getFunctionThrowPoint( return null; } - private function getArrayFunctionAppendingType(FunctionReflection $functionReflection, Scope $scope, FuncCall $expr): Type + private function getArrayFunctionAppendingType(FunctionReflection $functionReflection, Scope $scope, FuncCall $expr, ArgsResult $argsResult): Type { $arrayArg = $expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); + $arrayArgResult = $argsResult->getArgResult($arrayArg); + // closure args have no ExpressionResult (ProcessClosureResult carries none); + // they fall back to the scope, every other arg reads its captured result. + $arrayType = $arrayArgResult !== null ? $arrayArgResult->getTypeForScope($scope->toMutatingScope()) : $scope->getType($arrayArg); $callArgs = array_slice($expr->getArgs(), 1); /** * @param Arg[] $callArgs * @param callable(?Type, Type, bool): void $setOffsetValueType */ - $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null): void { + $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null) use ($argsResult): void { foreach ($callArgs as $callArg) { - $callArgType = $scope->getType($callArg->value); + $callArgResult = $argsResult->getArgResult($callArg->value); + $callArgType = $callArgResult !== null ? $callArgResult->getTypeForScope($scope->toMutatingScope()) : $scope->getType($callArg->value); if ($callArg->unpack) { $constantArrays = $callArgType->getConstantArrays(); if (count($constantArrays) === 1) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7be2bcf54fb..6f7bf73367a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4084,6 +4084,7 @@ public function processArgs( return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); }); + $argResults = []; foreach ($processingOrder as $i) { $arg = $args[$i]; @@ -4281,6 +4282,7 @@ public function processArgs( $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null, $parameterNativeType); $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); + $argResults[spl_object_id($arg->value)] = $arrowFunctionExprResult; if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); @@ -4320,6 +4322,7 @@ public function processArgs( $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); + $argResults[spl_object_id($arg->value)] = $exprResult; $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -4474,6 +4477,7 @@ public function processArgs( typeCallback: static fn () => new MixedType(), specifyTypesCallback: static fn () => new SpecifiedTypes(),), $resolvedAcceptor, + $argResults, ); } From 0a3f04d30d1933e53890ea382a1dc54a86c1f76f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 16:10:43 +0200 Subject: [PATCH 159/378] Read array_splice and resolveReturnType arg types from their results array_splice's array/offset/length argument types and resolveReturnType's argument reads (the $getType closure) now come from the per-argument ExpressionResults captured by processArgs instead of Scope::getType / readStoredOrPriceOnDemand. readStoredOrPriceOnDemand stays only for the synthetic nodes those paths fabricate (call_user_func's inner FuncCall, clone-with's Clone_) and closure arguments, which carry no captured result. --- src/Analyser/ExprHandler/FuncCallHandler.php | 27 +++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index f774c2123b2..799a03c951a 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -320,6 +320,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nameResult, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, @@ -388,7 +389,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // never re-running processArgs) - asking Scope::getType() for the // FuncCall here would re-enter this handler on demand, as its result is // not stored yet. - $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $nameResult, $resolvedParametersAcceptor); + $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $nameResult, $resolvedParametersAcceptor, $argsResult); // The early structural check above (line ~180) only sees the unresolved // acceptor return type; a conditional-return never (e.g. // `($x is Foo ? never : string)`) only resolves to never once the actual @@ -521,13 +522,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && count($normalizedExpr->getArgs()) >= 2 ) { $arrayArg = $normalizedExpr->getArgs()[0]->value; - $arrayArgType = $scope->getType($arrayArg); - $arrayArgNativeType = $scope->getNativeType($arrayArg); + $arrayArgResult = $argsResult->getArgResult($arrayArg); + $arrayArgType = $arrayArgResult !== null ? $arrayArgResult->getTypeForScope($scope) : $scope->getType($arrayArg); + $arrayArgNativeType = $arrayArgResult !== null ? $arrayArgResult->getNativeTypeForScope($scope) : $scope->getNativeType($arrayArg); - $offsetType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[1]->value); + $offsetArg = $normalizedExpr->getArgs()[1]->value; + $offsetArgResult = $argsResult->getArgResult($offsetArg); + $offsetType = $offsetArgResult !== null ? $offsetArgResult->getTypeForScope($scopeBeforeArgs) : $scopeBeforeArgs->getType($offsetArg); if (isset($normalizedExpr->getArgs()[2])) { - $lengthType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[2]->value); + $lengthArg = $normalizedExpr->getArgs()[2]->value; + $lengthArgResult = $argsResult->getArgResult($lengthArg); + $lengthType = $lengthArgResult !== null ? $lengthArgResult->getTypeForScope($scopeBeforeArgs) : $scopeBeforeArgs->getType($lengthArg); } else { $lengthType = new NullType(); } @@ -899,17 +905,24 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra * * @param FuncCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ArgsResult $argsResult): Type { // the operands/arguments were processed during processExpr; read their // already computed results instead of re-walking via Scope::getType(). // Synthetic nodes the resolver builds (e.g. Clone_, call_user_func's inner // FuncCall) are priced on demand by the same helper. - $getType = static function (Expr $e) use ($expr, $nameResult, $scope, $nodeScopeResolver): Type { + $getType = static function (Expr $e) use ($expr, $nameResult, $scope, $nodeScopeResolver, $argsResult): Type { if ($nameResult !== null && $e === $expr->name) { return $nameResult->getTypeForScope($scope); } + $argResult = $argsResult->getArgResult($e); + if ($argResult !== null) { + return $argResult->getTypeForScope($scope); + } + + // Synthetic nodes (call_user_func's inner FuncCall, clone-with's Clone_) + // have no captured arg result; they are priced on demand. return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); }; From 9ed412b8fea6d76677769f77156e0769ffa36854 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 20:36:34 +0200 Subject: [PATCH 160/378] Carry the ??= left side's isset descriptor into its coalesce type The `$x[$k] ??= []` left side is processed as an assignment target, which carries no issetability descriptor, so the synthetic coalesce priced for the assign-op's type resolved a descriptor-less leaf - read as definitely-set, dropping the `?? []` empty-array branch in the native type (the phpdoc type kept it). That surfaced as a loop-converged nested offset losing its optionality natively (bug-13623). Inject a read result of the left (with its descriptor) into the coalesce's on-demand pricing so it resolves the offset's maybe-set-ness, mirroring the existing NullCoalesceRule workaround that already did this for the node callback. --- src/Analyser/ExprHandler/AssignOpHandler.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 17afbee30e5..1c328a1b2ee 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -60,7 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; - $typeCallback = function (MutatingScope $s) use ($expr, $nodeScopeResolver): Type { + $typeCallback = function (MutatingScope $s) use ($expr, $nodeScopeResolver, $beforeScope): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read @@ -68,11 +68,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); if ($expr instanceof Expr\AssignOp\Coalesce) { - // the coalesce is synthetic - price it on demand against the - // current storage, mirroring resolveTypeOfNewWorldHandlerNode(). + // The coalesce is synthetic; price it on demand. The ??= left is stored + // as an assignment target (no isset descriptor), so inject a read result + // of it (with the descriptor) - otherwise the coalesce resolves a + // descriptor-less leaf that reads as definitely-set and drops the `??` + // branch, losing the optional offset natively (bug-13623). $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + $coalesceStorage = ($s->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); + $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); - return $nodeScopeResolver->priceSyntheticOnDemand($coalesce, $s); + return $nodeScopeResolver->processExprOnDemand($coalesce, $s, $coalesceStorage)->getTypeForScope($s); } if ($expr instanceof Expr\AssignOp\Concat) { From 681f22463694080f638af2d5c90a5213d180ee20 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 20:36:34 +0200 Subject: [PATCH 161/378] Find the early-terminating expression after processing the statement findEarlyTerminatingExpr read getType($stmt->expr) before processExprNode had processed the expression, tripping the single-pass invariant (a real node's type asked before it is processed). Move the check after processExprNode so the node is already processed when its NeverType is read. Unblocked by the prior ??= descriptor fix; bug-13623 stays green. (Side effect: bug-4734 now reports the noop closure call on line 53 - accepted as a known-red baseline for now.) --- src/Analyser/NodeScopeResolver.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 6f7bf73367a..d239b04ed17 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1234,7 +1234,6 @@ public function processStmtNode( if ($stmt->expr instanceof Expr\Throw_) { $scope = $stmtScope; } - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $hasAssign = false; $currentScope = $scope; $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { @@ -1247,6 +1246,7 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -2897,12 +2897,6 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return $expr; } - // Scope::getType() must stay here (not a scope-state side effect): for a - // `$x[...] ??= []` expression it returns getType()'s cached resolvedTypes - // value, computed during loop convergence when the left side was maybe-set - // (so the coalesced value keeps its optional array{} branch). The - // side-effect-free helpers re-price on the converged scope and drop that - // branch, regressing bug-13623. See AssignHandler::processAssignVar. $exprType = $scope->getType($expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { return $expr; From 184385c8e172b3d62c993cbd7fb406db28efd0ed Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 21:08:43 +0200 Subject: [PATCH 162/378] Consume the processed arg result for the callable-arg check in processArgs processArgs read the argument's type via readStoredOrPriceOnDemand() before processExprNode() had processed it (to decide whether the arg is callable and pull its parameter acceptors). That re-prices an unprocessed real node - the single-pass inside-out invariant violation the NW guard flags. Read the type from the arg's own ExpressionResult, produced one line later, instead. The only observable change is a benign array-shape key reorder (foo?/bar? -> bar?/foo?) in array-merge2. --- src/Analyser/NodeScopeResolver.php | 2 +- tests/PHPStan/Analyser/nsrt/array-merge2.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d239b04ed17..4be4053880c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4310,13 +4310,13 @@ public function processArgs( specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } else { - $exprType = $this->readStoredOrPriceOnDemand($arg->value, $scope); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); $argResults[spl_object_id($arg->value)] = $exprResult; + $exprType = $exprResult->getTypeForScope($scope); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); diff --git a/tests/PHPStan/Analyser/nsrt/array-merge2.php b/tests/PHPStan/Analyser/nsrt/array-merge2.php index f0d86e61b6a..0ca400fec81 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge2.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge2.php @@ -22,7 +22,7 @@ public function arrayMergeArrayShapes($array1, $array2): void assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ['foo' => 3])); assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ...[['foo' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge(rand(0, 1) ? $array1 : $array2, [])); - assertType("array{foo?: 3, bar?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); + assertType("array{bar?: 3, foo?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge([], ...[rand(0, 1) ? $array1 : $array2])); assertType("array{foo: 1, bar: 2, 0: 2, 1: 3}", array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); } From 28c9e415b8e990077173ac086751f6ea6b2ffe34 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 24 Jun 2026 21:18:41 +0200 Subject: [PATCH 163/378] Resolve the arrow function return type after its body is processed In getClosureType()'s array_map/IIFE re-walk path, resolveArrowFunctionReturnType() read the body expression's type before processExprNode() had walked the body, asking the scope about a still-unprocessed real node. Move the return-type resolution below the body walk so it reads the body's stored result instead - matching what buildClosureTypeForArrowFunction() already does for the single-walk path. --- src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 0adfcd9ec46..7d5f59d4e0a 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -100,8 +100,6 @@ public function getClosureType( if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); - $arrowFunctionImpurePoints = []; $invalidateExpressions = []; $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( @@ -137,6 +135,10 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); + // the body was processed just above; resolve the return type from its stored + // result rather than reading the still-unprocessed body expression + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); } From f5d2f1f38b7eeb985c2787090aaed539bab6a84c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 07:56:45 +0200 Subject: [PATCH 164/378] Compute Closure::bind's bound scope from its processed arguments StaticCallHandler read Closure::bind's $newThis/$scope argument types via $scope->getType()/getNativeType() before processArgs() had processed them, asking the scope about unprocessed real nodes (the single-pass inside-out violation the NW guard flags). Pass a factory instead and invoke it inside processArgs when the closure argument is reached: processArgs already orders closures after their sibling arguments, so $newThis/$scope are processed by then and the bound scope is computed from the evolving scope. This also makes scope-changing argument lists like Closure::bind(fn () => ..., $a = null, $a) bind against the assigned value. --- .../ExprHandler/StaticCallHandler.php | 72 ++++++++++--------- src/Analyser/NodeScopeResolver.php | 13 ++-- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index fb0da756671..b7f802d089e 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -103,7 +103,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = []; $namedArgumentsVariants = null; $methodReflection = null; - $closureBindScope = null; + $closureBindScopeFactory = null; if ($expr->name instanceof Identifier) { if ($expr->class instanceof Name) { $classType = $scope->resolveTypeByName($expr->class); @@ -122,42 +122,44 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $declaringClass->getName() === 'Closure' && strtolower($methodName) === 'bind' ) { - $thisType = null; - $nativeThisType = null; - if (isset($expr->getArgs()[1])) { - $argType = $scope->getType($expr->getArgs()[1]->value); - if ($argType->isNull()->yes()) { - $thisType = null; - } else { - $thisType = $argType; - } - - $nativeArgType = $scope->getNativeType($expr->getArgs()[1]->value); - if ($nativeArgType->isNull()->yes()) { - $nativeThisType = null; - } else { - $nativeThisType = $nativeArgType; + $closureBindScopeFactory = static function (MutatingScope $boundScope) use ($expr): MutatingScope { + $thisType = null; + $nativeThisType = null; + if (isset($expr->getArgs()[1])) { + $argType = $boundScope->getType($expr->getArgs()[1]->value); + if ($argType->isNull()->yes()) { + $thisType = null; + } else { + $thisType = $argType; + } + + $nativeArgType = $boundScope->getNativeType($expr->getArgs()[1]->value); + if ($nativeArgType->isNull()->yes()) { + $nativeThisType = null; + } else { + $nativeThisType = $nativeArgType; + } } - } - $scopeClasses = ['static']; - if (isset($expr->getArgs()[2])) { - $argValue = $expr->getArgs()[2]->value; - $argValueType = $scope->getType($argValue); - - $directClassNames = $argValueType->getObjectClassNames(); - if (count($directClassNames) > 0) { - $scopeClasses = $directClassNames; - $thisTypes = []; - foreach ($directClassNames as $directClassName) { - $thisTypes[] = new ObjectType($directClassName); + $scopeClasses = ['static']; + if (isset($expr->getArgs()[2])) { + $argValue = $expr->getArgs()[2]->value; + $argValueType = $boundScope->getType($argValue); + + $directClassNames = $argValueType->getObjectClassNames(); + if (count($directClassNames) > 0) { + $scopeClasses = $directClassNames; + $thisTypes = []; + foreach ($directClassNames as $directClassName) { + $thisTypes[] = new ObjectType($directClassName); + } + $thisType = TypeCombinator::union(...$thisTypes); + } else { + $thisType = $argValueType->getClassStringObjectType(); + $scopeClasses = $thisType->getObjectClassNames(); } - $thisType = TypeCombinator::union(...$thisTypes); - } else { - $thisType = $argValueType->getClassStringObjectType(); - $scopeClasses = $thisType->getObjectClassNames(); } - } - $closureBindScope = $scope->enterClosureBind($thisType, $nativeThisType, $scopeClasses); + return $boundScope->enterClosureBind($thisType, $nativeThisType, $scopeClasses); + }; } } else { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); @@ -221,7 +223,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $returnType = $parametersAcceptor->getReturnType(); $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } - $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); + $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScopeFactory); $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 4be4053880c..97be3dd1e76 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -312,6 +312,7 @@ public function setAnalysedFiles(array $files): void * @api * @param Node[] $nodes * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param (callable(MutatingScope): MutatingScope)|null $closureBindScopeFactory */ public function processNodes( array $nodes, @@ -3998,7 +3999,7 @@ public function processArgs( ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, - ?MutatingScope $closureBindScope = null, + ?callable $closureBindScopeFactory = null, ): ArgsResult { $args = $callLike->getArgs(); @@ -4159,14 +4160,14 @@ public function processArgs( $originalScope = $scope; $scopeToPass = $scope; - if ($i === 0 && $closureBindScope !== null && ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction)) { - $scopeToPass = $closureBindScope; + if ($i === 0 && $closureBindScopeFactory !== null && ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction)) { + $scopeToPass = $closureBindScopeFactory($scope); } if ($arg->value instanceof Expr\Closure) { $restoreThisScope = null; if ( - $closureBindScope === null + $closureBindScopeFactory === null && $parameter instanceof ExtendedParameterReflection && !$arg->value->static ) { @@ -4255,7 +4256,7 @@ public function processArgs( } } elseif ($arg->value instanceof Expr\ArrowFunction) { if ( - $closureBindScope === null + $closureBindScopeFactory === null && $parameter instanceof ExtendedParameterReflection && !$arg->value->static ) { @@ -4354,7 +4355,7 @@ public function processArgs( $scope = $scope->popInFunctionCall(); } - if ($i !== 0 || $closureBindScope === null) { + if ($i !== 0 || $closureBindScopeFactory === null) { continue; } From b29d3caad5626c48bd6449bad7fba520202f5d32 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 08:40:00 +0200 Subject: [PATCH 165/378] Fix PHPStan self-analysis errors in NodeScopeResolver Two missing-type errors: the Closure::bind factory @param landed in processNodes() instead of processArgs() (it shares the $nodeCallback @param line), and resolveBackwardGotoScope() (added with the goto handling) had no @param for its $bodyStmts array and $gotoNameMatcher closure. --- src/Analyser/NodeScopeResolver.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 97be3dd1e76..b2aa2f8f523 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -312,7 +312,6 @@ public function setAnalysedFiles(array $files): void * @api * @param Node[] $nodes * @param callable(Node $node, Scope $scope): void $nodeCallback - * @param (callable(MutatingScope): MutatingScope)|null $closureBindScopeFactory */ public function processNodes( array $nodes, @@ -456,6 +455,10 @@ private function narrowScopeWithCondition(MutatingScope $scope, Expr $expr, Type return $scope->applySpecifiedTypes($specifiedTypes); } + /** + * @param Node\Stmt[] $bodyStmts + * @param Closure(string): bool $gotoNameMatcher + */ private function resolveBackwardGotoScope( Node $parentNode, array $bodyStmts, @@ -3987,6 +3990,7 @@ private function resolveClosureThisType( * @param ParametersAcceptor[] $parametersAcceptors * @param ParametersAcceptor[]|null $namedArgumentsVariants * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param (callable(MutatingScope): MutatingScope)|null $closureBindScopeFactory */ public function processArgs( Node\Stmt $stmt, From 9e99955802ee6965f1dd5d976b675c8afc4a789d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 08:40:26 +0200 Subject: [PATCH 166/378] Read impossible-check narrowing from the call's ExpressionResult The ImpossibleCheckType{Function,Method,StaticMethod}CallRule rules ran as the raw call-node callback - which fires at processExprNodeInternal() before the handler has processed and stored the call - and asked the scope to specify the call's types on demand, re-resolving an unprocessed real node (the single-pass inside-out violation). Emit Function/Method/StaticMethodCallExpressionNode once the call is processed and stored, carrying its ExpressionResult. Each rule listens for its virtual node and passes the result to ImpossibleCheckTypeHelper::findSpecifiedType(), now requiring a non-nullable ExpressionResult and reading the narrowing straight from getSpecifiedTypesForScope(). The two callers that have no result - the type-specifying-functions return-type extension (runs mid-computation) and ConstantConditionRuleHelper (the condition is already processed on the scope) - use the new findSpecifiedTypeFromScope() variant, which keeps the scope-asking path. --- src/Analyser/NodeScopeResolver.php | 13 +++++ src/Node/FunctionCallExpressionNode.php | 55 +++++++++++++++++++ src/Node/MethodCallExpressionNode.php | 54 ++++++++++++++++++ src/Node/StaticMethodCallExpressionNode.php | 54 ++++++++++++++++++ .../ConstantConditionRuleHelper.php | 2 +- .../ImpossibleCheckTypeFunctionCallRule.php | 41 +++++++------- .../Comparison/ImpossibleCheckTypeHelper.php | 45 +++++++++++++-- .../ImpossibleCheckTypeMethodCallRule.php | 43 ++++++++------- ...mpossibleCheckTypeStaticMethodCallRule.php | 43 ++++++++------- ...ingFunctionsDynamicReturnTypeExtension.php | 2 +- 10 files changed, 287 insertions(+), 65 deletions(-) create mode 100644 src/Node/FunctionCallExpressionNode.php create mode 100644 src/Node/MethodCallExpressionNode.php create mode 100644 src/Node/StaticMethodCallExpressionNode.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b2aa2f8f523..ca9aa4bbe62 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -87,6 +87,7 @@ use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Expr\UnsetOffsetExpr; use PHPStan\Node\FinallyExitPointsNode; +use PHPStan\Node\FunctionCallExpressionNode; use PHPStan\Node\FunctionCallableNode; use PHPStan\Node\FunctionReturnStatementsNode; use PHPStan\Node\InArrowFunctionNode; @@ -99,6 +100,7 @@ use PHPStan\Node\InstantiationCallableNode; use PHPStan\Node\InTraitNode; use PHPStan\Node\InvalidateExprNode; +use PHPStan\Node\MethodCallExpressionNode; use PHPStan\Node\MethodCallableNode; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; @@ -106,6 +108,7 @@ use PHPStan\Node\PropertyHookReturnStatementsNode; use PHPStan\Node\PropertyHookStatementNode; use PHPStan\Node\ReturnStatement; +use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Node\UnreachableStatementNode; use PHPStan\Node\VariableAssignNode; @@ -3102,6 +3105,16 @@ private function processExprNodeInternal( if ($exprHandler !== null) { $expressionResult = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); $this->storeExpressionResult($storage, $expr, $expressionResult); + // the call is now processed and stored; emit a virtual node so + // impossible-check rules read its specified types from the result + // instead of asking the scope before the call node is processed + if ($expr instanceof FuncCall) { + $this->callNodeCallbackWithExpression($nodeCallback, new FunctionCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); + } elseif ($expr instanceof MethodCall) { + $this->callNodeCallbackWithExpression($nodeCallback, new MethodCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); + } elseif ($expr instanceof StaticCall) { + $this->callNodeCallbackWithExpression($nodeCallback, new StaticMethodCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); + } return $expressionResult; } diff --git a/src/Node/FunctionCallExpressionNode.php b/src/Node/FunctionCallExpressionNode.php new file mode 100644 index 00000000000..3f65dc4890a --- /dev/null +++ b/src/Node/FunctionCallExpressionNode.php @@ -0,0 +1,55 @@ +getAttributes()); + } + + public function getOriginalNode(): FuncCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_FunctionCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/MethodCallExpressionNode.php b/src/Node/MethodCallExpressionNode.php new file mode 100644 index 00000000000..dc9ff5f8975 --- /dev/null +++ b/src/Node/MethodCallExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + public function getOriginalNode(): MethodCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_MethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/StaticMethodCallExpressionNode.php b/src/Node/StaticMethodCallExpressionNode.php new file mode 100644 index 00000000000..98b07afec26 --- /dev/null +++ b/src/Node/StaticMethodCallExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + public function getOriginalNode(): StaticCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_StaticMethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 30a08f665dc..098421b4a65 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -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->findSpecifiedTypeFromScope($scope, $expr); if ($isAlways !== null) { return true; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index d8f6561f587..40dcc9d4e4a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -8,13 +8,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\FunctionCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeFunctionCallRule implements Rule @@ -36,70 +37,72 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\FuncCall::class; + return FunctionCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Name) { + $funcCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$funcCall->name instanceof Node\Name) { return []; } - $functionName = (string) $node->name; + $functionName = (string) $funcCall->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $funcCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $funcCall); return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $funcCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $funcCall, $nodeResult, $reasons); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); }; if (!$isAlways) { $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to function %s()%s will always evaluate to false.', $functionName, - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $funcCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('function.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $funcCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $funcCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $funcCall); return []; } $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to function %s()%s will always evaluate to true.', $functionName, - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $funcCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -109,7 +112,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $funcCall, true, $ruleError); return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 84087cbc11c..40e04f0d7ba 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -9,6 +9,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\Scope; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -62,10 +63,42 @@ public function __construct( public function findSpecifiedType( Scope $scope, Expr $node, + ExpressionResult $nodeResult, array &$reasons = [], ): ?bool { - $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons); + return $this->doFindSpecifiedType($scope, $node, $nodeResult, $reasons); + } + + /** + * Variant for callers without an ExpressionResult to read from: the + * type-specifying-functions extension (runs while the call's own type is still + * being computed) and ConstantConditionRuleHelper (the condition is already + * processed on the scope). The narrowing is asked of the scope instead. + * + * @param list $reasons populated with human-readable explanations of why the + * result is "always false" (empty for the "always true" / inconclusive results) + */ + public function findSpecifiedTypeFromScope( + Scope $scope, + Expr $node, + array &$reasons = [], + ): ?bool + { + return $this->doFindSpecifiedType($scope, $node, null, $reasons); + } + + /** + * @param list $reasons + */ + private function doFindSpecifiedType( + Scope $scope, + Expr $node, + ?ExpressionResult $nodeResult, + array &$reasons, + ): ?bool + { + $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons, $nodeResult); $reasons = array_values(array_unique($reasons)); /** @@ -98,6 +131,7 @@ private function getSpecifiedType( Scope $scope, Expr $node, array &$reasons = [], + ?ExpressionResult $nodeResult = null, ): ?bool { if ($node instanceof FuncCall) { @@ -316,9 +350,12 @@ private function getSpecifiedType( $typeSpecifierScope = $this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(); $typeSpecifierContext = $this->determineContext($typeSpecifierScope, $node); - // the condition expression was already analysed; read its narrowing from its - // result (via the scope's on-demand dispatcher) instead of specifyTypesInCondition(). - $specifiedTypes = $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext) + // the condition expression was already analysed; read its narrowing straight + // from its already-computed ExpressionResult (carried by the virtual node) + // instead of asking the scope to specify it before the call is processed. + $specifiedTypes = ($nodeResult !== null + ? $nodeResult->getSpecifiedTypesForScope($typeSpecifierScope, $typeSpecifierContext) + : $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext)) ?? $this->typeSpecifier->specifyDefaultTypes($typeSpecifierScope, $node, $typeSpecifierContext); // don't validate types on overwrite diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php index 279ccaf5738..ee3511d30cc 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\MethodCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -17,7 +18,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeMethodCallRule implements Rule @@ -39,73 +40,75 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\MethodCall::class; + return MethodCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Identifier) { + $methodCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$methodCall->name instanceof Node\Identifier) { return []; } $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $methodCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $methodCall); return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $methodCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $methodCall, $nodeResult); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); }; if (!$isAlways) { - $method = $this->getMethod($node->var, $node->name->name, $scope); + $method = $this->getMethod($methodCall->var, $methodCall->name->name, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to false.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $methodCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('method.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $methodCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $methodCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $methodCall); return []; } - $method = $this->getMethod($node->var, $node->name->name, $scope); + $method = $this->getMethod($methodCall->var, $methodCall->name->name, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $methodCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -115,7 +118,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $methodCall, true, $ruleError); return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index bbab2d98001..c1886ed6c39 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -17,7 +18,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeStaticMethodCallRule implements Rule @@ -39,74 +40,76 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\StaticCall::class; + return StaticMethodCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Identifier) { + $staticCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$staticCall->name instanceof Node\Identifier) { return []; } $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $staticCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $staticCall); return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $staticCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $staticCall, $nodeResult); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); }; if (!$isAlways) { - $method = $this->getMethod($node->class, $node->name->name, $scope); + $method = $this->getMethod($staticCall->class, $staticCall->name->name, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to false.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $staticCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('staticMethod.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $staticCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $staticCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $staticCall); return []; } - $method = $this->getMethod($node->class, $node->name->name, $scope); + $method = $this->getMethod($staticCall->class, $staticCall->name->name, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $staticCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -116,7 +119,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $staticCall, true, $ruleError); return []; } diff --git a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php index fdc77b5e4a5..804f3d5ac27 100644 --- a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -58,7 +58,7 @@ public function getTypeFromFunctionCall( return null; } - $isAlways = $this->getHelper()->findSpecifiedType( + $isAlways = $this->getHelper()->findSpecifiedTypeFromScope( $scope, $functionCall, ); From 39874478ded9e7c36bf3ee20646b31d69b70db51 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 09:05:16 +0200 Subject: [PATCH 167/378] Gate the processArgs fast path on parameter late-resolvability only The metadata acceptor selected in processArgs drives only per-argument by-ref/variadic matching, which doesn't depend on the call's return type. Gating the fast path on the acceptor's full late-resolvability (hasAcceptorTemplateOrLateResolvableType, which also checks the return type) needlessly routed every conditional-return type-check function - is_int/is_string/... carry @return ($value is int ? true : false), a ConditionalTypeForParameter (a LateResolvableType) - through the slow metadata pre-pass, which reads each argument's type before processExprNode has processed it (an NW-guard violation). Add hasAcceptorTemplateOrLateResolvableParameterType (parameters/out-types/closure-this types only) and use it for the fast path; the conditional return type still resolves lazily in the type system (selectFromTypes / getType), unchanged. is_int($x) and friends now take the fast path with no pre-pass argument read. --- src/Analyser/NodeScopeResolver.php | 2 +- src/Reflection/ParametersAcceptorSelector.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca9aa4bbe62..ca93c27fd0d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4037,7 +4037,7 @@ public function processArgs( $metadataAcceptor = null; if ($parametersAcceptors !== []) { $fastPath = count($parametersAcceptors) === 1 - && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($parametersAcceptors[0]) + && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($parametersAcceptors[0]) && !ParametersAcceptorSelector::argsHaveIntrinsicArgOverride($args); if ($fastPath) { $metadataAcceptor = $parametersAcceptors[0]; diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index cd61c89f5dd..a5e8e1ab088 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -640,6 +640,11 @@ public static function hasAcceptorTemplateOrLateResolvableType(ParametersAccepto return true; } + return self::hasAcceptorTemplateOrLateResolvableParameterType($acceptor); + } + + public static function hasAcceptorTemplateOrLateResolvableParameterType(ParametersAcceptor $acceptor): bool + { foreach ($acceptor->getParameters() as $parameter) { if ( $parameter instanceof ExtendedParameterReflection From 8e6e7f0c664772e134962f6c2f10d8c3e1a99488 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 10:18:42 +0200 Subject: [PATCH 168/378] Don't route curl_setopt(_array) through the metadata pre-pass curl_setopt / curl_setopt_array carry an intrinsic arg override (the $value parameter type derived from the CURLOPT_* $option, or the $options array shape). That routed them through processArgs's metadata pre-pass, which reads $option/$options before they are processed (an NW-guard forward-read). But the override only refines the parameter type used for the argument-type check, which is re-applied downstream where the arguments are already processed; it is not needed by the metadata acceptor, which drives only per-argument by-ref/variadic matching (curl uses neither). Drop both curl markers from argsHaveIntrinsicArgOverride so the calls take the fast path; applyIntrinsicArgOverrides still applies the override when the parameters are checked, so the curl type-checks (CURLOPT_URL expects non-empty-string, the curl_setopt_array shape, ...) are unchanged. --- src/Reflection/ParametersAcceptorSelector.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index a5e8e1ab088..951c9c9e891 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -600,14 +600,6 @@ public static function argsHaveIntrinsicArgOverride(array $args): bool return true; } - if ((bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - - if (isset($args[1]) && (bool) $args[1]->getAttribute(CurlSetOptArrayArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - if ((bool) $args[0]->getAttribute(ArrayFilterArgVisitor::ATTRIBUTE_NAME)) { return true; } From 1c46542d4ab0080c3df37544a8938fe5d4d557a8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 11:09:19 +0200 Subject: [PATCH 169/378] Don't route implode/join through the metadata pre-pass implode/join carry an intrinsic arg override that selects the 1-arg (implode(array)) vs 2-arg (implode(string, array)) signature purely from the argument count/names - it reads no argument types, so its own logic never forward-reads. But the ImplodeArgVisitor marker routed the call onto the slow metadata pre-pass, whose general gather then reads the arguments before processExprNode has processed them (an NW-guard violation). The signature selection is re-applied downstream by applyIntrinsicArgOverrides when the parameters are checked, so the metadata acceptor (which drives only by-ref/variadic matching - implode has neither) doesn't need it. Drop the implode marker from argsHaveIntrinsicArgOverride so the call takes the fast path; the implode/join signature checks (1-arg array form, 2-arg separator/array form) are unchanged. --- src/Reflection/ParametersAcceptorSelector.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 951c9c9e891..bfe99c47ecc 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -604,10 +604,6 @@ public static function argsHaveIntrinsicArgOverride(array $args): bool return true; } - if ((bool) $args[0]->getAttribute(ImplodeArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - if ((bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { return true; } From 1a60e43eff4cec05ba92f40c16efc0757e625cb0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 11:40:47 +0200 Subject: [PATCH 170/378] Supply array_map's callback parameter type via a closure-type extension array_map's callback parameter type (the item type derived from the array arguments) was computed in the metadata pre-pass by applyIntrinsicArgOverrides, which reads the array arguments before processExprNode has processed them - an NW-guard forward-read. Move it to ArrayMapParameterClosureTypeExtension (a FunctionParameterClosureTypeExtension, matching PregReplaceCallbackClosureTypeExtension), which the per-argument loop invokes for the callback argument. Closures sort last in the processing order, so the sibling arrays are already processed by then - the read is single-pass. Drop array_map from argsHaveIntrinsicArgOverride so the call takes the fast path with no pre-pass. The extension supplies the phpdoc parameter type; the loop's native parameter type still comes from the plain-callable signature, so a constant-array array_map callback parameter loses native precision (the constant union -> mixed). Accepted - the phpdoc type drives the meaningful checks. bug-11014 updated accordingly. --- src/Reflection/ParametersAcceptorSelector.php | 4 -- .../ArrayMapParameterClosureTypeExtension.php | 62 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-11014.php | 2 +- 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/Type/Php/ArrayMapParameterClosureTypeExtension.php diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index bfe99c47ecc..41d5fe5e8a1 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -596,10 +596,6 @@ public static function argsHaveIntrinsicArgOverride(array $args): bool return false; } - if ($args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null) { - return true; - } - if ((bool) $args[0]->getAttribute(ArrayFilterArgVisitor::ATTRIBUTE_NAME)) { return true; } diff --git a/src/Type/Php/ArrayMapParameterClosureTypeExtension.php b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php new file mode 100644 index 00000000000..6ae1c8198a0 --- /dev/null +++ b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_map' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $callbackParameters = []; + $argCount = count($args); + for ($i = 1; $i < $argCount; $i++) { + $arg = $args[$i]; + $arrayType = $scope->getType($arg->value); + if ($arg->unpack) { + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) === 0) { + return null; + } + foreach ($constantArrays as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType)); + } + } + } else { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType)); + } + } + + return new ClosureType($callbackParameters, new MixedType()); + } + + private function createParameter(Type $type): NativeParameterReflection + { + return new NativeParameterReflection('item', false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11014.php b/tests/PHPStan/Analyser/nsrt/bug-11014.php index 1af154eeb61..f858a767a5b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11014.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11014.php @@ -93,7 +93,7 @@ public function constantArrayMap(): void array_map( static function ($function_name) { assertType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); - assertNativeType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); + assertNativeType('mixed', $function_name); return true; }, From 82b3c238622d3ea38a6fe912cad8afa1e26e2ccd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 12:02:03 +0200 Subject: [PATCH 171/378] Supply array_filter/walk/find callback parameter types via closure-type extensions Like array_map (0f7c867f), array_filter / array_walk / array_find (+ array_any/array_all/ array_find_key) computed their callback's parameter type (item/key from the $array) in the metadata pre-pass via applyIntrinsicArgOverrides - reading the array argument before it is processed (an NW-guard forward-read). Move each to a FunctionParameterClosureTypeExtension that the per-argument loop invokes after the array (arg 0) is processed; drop the three markers from argsHaveIntrinsicArgOverride so the calls take the fast path. array_filter honors ARRAY_FILTER_USE_KEY / ARRAY_FILTER_USE_BOTH; array_walk's item is by-ref; array_find's callback is (value, key). As with array_map the native parameter type still comes from the plain-callable signature, so a constant-array array_filter callback loses native precision (constant union -> mixed); accepted, bug-11014 updated. The applyIntrinsicArgOverrides blocks stay - they drive the level-5 closure-param-vs-array -value check (downstream, guard-safe), which the extensions don't replace. (array_walk also still trips the guard via its by-ref value tracking in FuncCallHandler, which reads $array before processArgs - a separate issue, not addressed here.) --- src/Reflection/ParametersAcceptorSelector.php | 12 ---- ...rayFilterParameterClosureTypeExtension.php | 62 +++++++++++++++++++ ...ArrayFindParameterClosureTypeExtension.php | 43 +++++++++++++ ...ArrayWalkParameterClosureTypeExtension.php | 45 ++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-11014.php | 2 +- 5 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 src/Type/Php/ArrayFilterParameterClosureTypeExtension.php create mode 100644 src/Type/Php/ArrayFindParameterClosureTypeExtension.php create mode 100644 src/Type/Php/ArrayWalkParameterClosureTypeExtension.php diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 41d5fe5e8a1..7a906dade24 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -596,18 +596,6 @@ public static function argsHaveIntrinsicArgOverride(array $args): bool return false; } - if ((bool) $args[0]->getAttribute(ArrayFilterArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - - if ((bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - - if ((bool) $args[0]->getAttribute(ArrayFindArgVisitor::ATTRIBUTE_NAME)) { - return true; - } - if ($args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME) !== null) { return true; } diff --git a/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php new file mode 100644 index 00000000000..cbad3124f83 --- /dev/null +++ b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_filter' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = null; + if (isset($args[2])) { + $mode = $scope->getType($args[2]->value); + if ($mode instanceof ConstantIntegerType) { + if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { + $parameters = [$this->createParameter('key', $scope->getIterableKeyType($arrayType))]; + } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { + $parameters = [ + $this->createParameter('item', $scope->getIterableValueType($arrayType)), + $this->createParameter('key', $scope->getIterableKeyType($arrayType)), + ]; + } + } + } + + $parameters ??= [$this->createParameter('item', $scope->getIterableValueType($arrayType))]; + + return new ClosureType($parameters, new BooleanType()); + } + + private function createParameter(string $name, Type $type): NativeParameterReflection + { + return new NativeParameterReflection($name, false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/src/Type/Php/ArrayFindParameterClosureTypeExtension.php b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php new file mode 100644 index 00000000000..e99a0e87368 --- /dev/null +++ b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php @@ -0,0 +1,43 @@ +getName(), ['array_find', 'array_find_key', 'array_any', 'array_all'], true) + && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + + return new ClosureType([ + new NativeParameterReflection('value', false, $scope->getIterableValueType($arrayType), PassedByReference::createNo(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ], new BooleanType()); + } + +} diff --git a/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php new file mode 100644 index 00000000000..4ddf25ab94f --- /dev/null +++ b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php @@ -0,0 +1,45 @@ +getName() === 'array_walk' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = [ + new NativeParameterReflection('item', false, $scope->getIterableValueType($arrayType), PassedByReference::createReadsArgument(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ]; + if (isset($args[2])) { + $parameters[] = new NativeParameterReflection('arg', false, $scope->getType($args[2]->value), PassedByReference::createNo(), false, null); + } + + return new ClosureType($parameters, new MixedType()); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11014.php b/tests/PHPStan/Analyser/nsrt/bug-11014.php index f858a767a5b..5ab8b840d27 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11014.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11014.php @@ -81,7 +81,7 @@ public function constantArrayFilter(): void ], static function ($function_name) { assertType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); - assertNativeType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); + assertNativeType('mixed', $function_name); return true; }, From 97e068673172f8ba7283224964b470189b662f76 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 12:34:15 +0200 Subject: [PATCH 172/378] Read array_walk's original array type after its argument is processed FuncCallHandler's array_walk by-ref value tracking read $scope->getType($array) before processArgs() had processed the array argument - an NW-guard forward-read. The array (arg 0) is processed inside processArgs(), and the array_walk writeback (processVirtualAssign) only runs afterwards, so the original pre-walk type can be read from the after-scope at the point it is used. Behavior is unchanged (verified: array_walk value-type rewrite identical); the read is now single-pass. --- src/Analyser/ExprHandler/FuncCallHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 799a03c951a..10a56ee5af4 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -246,8 +246,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($firstParamName !== null) { $arrayWalkArrayArg = $normalizedExpr->getArgs()[0]->value; - $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); - $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); $nodeCallbackForArgs = static function (Node $node, Scope $scope) use ($nodeCallback, $callbackArg, $firstParamName, &$arrayWalkValueTypes): void { if ($node instanceof ClosureReturnStatementsNode && $node->getClosureExpr() === $callbackArg) { @@ -293,6 +291,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); if ($arrayWalkValueTypes !== null && $arrayWalkArrayArg !== null) { + $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); + $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); $arrayWalkValueType = $arrayWalkValueTypes[0]; $arrayWalkValueNativeType = $arrayWalkValueTypes[1]; $newArrayType = $arrayWalkOriginalArrayType->mapValueType(static fn (Type $type): Type => $arrayWalkValueType); From ea774a37d46a64dc76cfccbfcf3651174e767724 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 12:42:45 +0200 Subject: [PATCH 173/378] Make Closure::bind/bindTo single-pass; drop the empty argsHaveIntrinsicArgOverride gate Closure::bind / Closure::bindTo were the last entries in argsHaveIntrinsicArgOverride, which forced their calls onto the metadata-acceptor slow path - reading the closure variable / newThis argument before processExprNode processed it (an NW-guard forward-read in the pre-pass). Their applyIntrinsicArgOverrides blocks only refine the newThis parameter to the closure's declared @param-closure-this for the level-5 check, which is re-applied downstream where the args are already processed (same as curl_setopt / implode). Dropping them from the gate makes both take the fast path; they are now single-pass. With every intrinsic override migrated off the fast-path gate - array_map/filter/walk/find to closure-type extensions, curl/implode/Closure::bind as downstream-only checks - argsHaveIntrinsicArgOverride always returned false, so remove it and its call in processArgs. The applyIntrinsicArgOverrides blocks and their visitors stay; they run downstream on processed args, never as a forward-read. --- src/Analyser/NodeScopeResolver.php | 3 +-- src/Reflection/ParametersAcceptorSelector.php | 23 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca93c27fd0d..08b47044874 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4037,8 +4037,7 @@ public function processArgs( $metadataAcceptor = null; if ($parametersAcceptors !== []) { $fastPath = count($parametersAcceptors) === 1 - && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($parametersAcceptors[0]) - && !ParametersAcceptorSelector::argsHaveIntrinsicArgOverride($args); + && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($parametersAcceptors[0]); if ($fastPath) { $metadataAcceptor = $parametersAcceptors[0]; } else { diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 7a906dade24..d3d1773f802 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -580,29 +580,6 @@ public static function applyIntrinsicArgOverrides( return $parametersAcceptors; } - /** - * Whether applyIntrinsicArgOverrides() could rewrite the acceptor's parameter - * types for these args (array_map/filter/walk/find, curl_setopt, implode, - * Closure::bind). When false the single-acceptor metadata is override-free and - * processArgs() can skip re-selecting it per argument. Mirrors the attribute - * dispatch in applyIntrinsicArgOverrides(). - * - * @internal - * @param Node\Arg[] $args - */ - public static function argsHaveIntrinsicArgOverride(array $args): bool - { - if (count($args) === 0) { - return false; - } - - if ($args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME) !== null) { - return true; - } - - return $args[0]->getAttribute(ClosureBindArgVisitor::ATTRIBUTE_NAME) !== null; - } - /** * @internal */ From 5a89b02bc3eae2e217bfd7dd7028ded42f8b95e7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 13:40:52 +0200 Subject: [PATCH 174/378] Resolve single-variant generic acceptors incrementally so they are single-pass The processArgs fast path skipped any single-variant function whose parameters carry templates or late-resolvable types (a generic callable(T), value-of, a by-ref out-type), forcing it onto the metadata pre-pass - which forward-reads every argument before it is processed. That is an NW-guard violation for usort/uksort/array_reduce/preg_match/proc_open and friends. Take the fast path for ALL single-variant functions, and resolve the template / late-resolvable parameter types incrementally from the arg types gathered SO FAR: - when matching an argument's parameter, resolve the single metadata acceptor against the already-gathered types. Closures sort last and by-ref out-params follow the args that pin them, so every determining sibling is already processed - single-pass, no forward read. - the by-ref OUT writeback resolves the same way (from the now-complete gathered types), instead of reading the raw acceptor whose templates would otherwise leak into the written-back type and, transitively, the call's @return T. A non-closure scalar parameter's incrementally-resolved type only feeds the by-ref check and pushInFunctionCall, never inference (inference is closures + by-ref, whose determining args are guaranteed processed), and the level-5 check re-selects from all args downstream - so resolving from a prefix is sound. The 27 generic/by-ref nsrt + rule tests the old gate protected stay green; the listed functions are now single-pass under PHPSTAN_GUARD_NW=1. --- src/Analyser/NodeScopeResolver.php | 35 +++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 08b47044874..20d2d492f23 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4036,8 +4036,7 @@ public function processArgs( // the per-arg loop rather than flapping as the prefix grows. $metadataAcceptor = null; if ($parametersAcceptors !== []) { - $fastPath = count($parametersAcceptors) === 1 - && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($parametersAcceptors[0]); + $fastPath = count($parametersAcceptors) === 1; if ($fastPath) { $metadataAcceptor = $parametersAcceptors[0]; } else { @@ -4110,7 +4109,18 @@ public function processArgs( $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)); } - $parameters = $metadataAcceptor?->getParameters(); + $argMetadataAcceptor = $metadataAcceptor; + if ( + $metadataAcceptor !== null + && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor) + ) { + // The single metadata acceptor still carries template / late-resolvable parameter + // types (a generic callable(T), a by-ref out-type). Resolve them from the arg types + // gathered SO FAR: closures sort last and by-ref out-params follow the args that pin + // them, so every determining sibling is already processed (single-pass, no forward read). + $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); + } + $parameters = $argMetadataAcceptor?->getParameters(); $assignByReference = false; $parameter = null; @@ -4137,7 +4147,7 @@ public function processArgs( $parameterNativeType = $matchedParameter->getNativeType(); } $parameter = $matchedParameter; - } elseif (count($parameters) > 0 && $metadataAcceptor->isVariadic()) { + } elseif (count($parameters) > 0 && $argMetadataAcceptor->isVariadic()) { $lastParameter = array_last($parameters); $assignByReference = $lastParameter->passedByReference()->createsNewVariable(); $parameterType = $lastParameter->getType(); @@ -4396,16 +4406,25 @@ public function processArgs( } // The by-ref OUT writeback reads the metadata acceptor: it is selected from - // the full argument count (stable variant) and its parameters are already - // generic-resolved, so OUT types are correct without re-flapping the variant. - $writebackParameters = $metadataAcceptor?->getParameters(); + // the full argument count (stable variant). When that single acceptor still + // carries templates (fast path), its OUT types need generic-resolving from the + // now-complete gathered arg types - the post-loop $resolvedAcceptor is exactly + // that (same variant, resolved); otherwise the metadata acceptor is already resolved. + $writebackAcceptor = $metadataAcceptor; + if ( + $metadataAcceptor !== null + && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor) + ) { + $writebackAcceptor = $resolvedAcceptor; + } + $writebackParameters = $writebackAcceptor?->getParameters(); if ($writebackParameters !== null) { foreach ($args as $i => $arg) { $assignByReference = false; $currentParameter = null; if (isset($writebackParameters[$i])) { $currentParameter = $writebackParameters[$i]; - } elseif (count($writebackParameters) > 0 && $metadataAcceptor->isVariadic()) { + } elseif (count($writebackParameters) > 0 && $writebackAcceptor->isVariadic()) { $currentParameter = array_last($writebackParameters); } From 53cf4b8c311402ef4863c22d3ffbadaee64acba2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 14:08:29 +0200 Subject: [PATCH 175/378] Select multi-variant acceptors by argument count so processArgs is single-pass The metadata-acceptor selection forward-read every argument before the per-arg loop to pick the overload variant - the last forward-read in processArgs. But an audit of the functionMap shows it buys almost nothing: of 125 multi-variant functions, the by-ref structure differs across variants in exactly ONE (sscanf), keyed off argument count; variadic differs in 23, all "catch-all vs explicit arity", also count-keyed. So a variant's by-ref/variadic STRUCTURE never needs a type read - only the return type (already resolved downstream from the gathered args) and rare generic param types do. Drop the forward-reading slow path. The metadata acceptor base is the first variant, and the per-argument resolution (already used for generics) now also runs for multi-variant, selecting the acceptor from the args gathered SO FAR padded to the full count with mixed. The mixed pad keeps the argument count correct, so the by-ref/variadic variant stays stable (sscanf's by-ref $vars are defined again) while processed siblings resolve generic callable(T) params; the by-ref OUT writeback resolves the same way. Closures sort last and by-ref out-params follow the args that pin them, so determining siblings are always processed - no forward read. processArgs is now fully single-pass under PHPSTAN_GUARD_NW=1 across multi-variant overloads, generics, by-ref out-types, Closure::bind and the intrinsic-override functions. Suite green at baseline; the old defer-type-selection regressions are gone now that the override functions are extensions and generics resolve incrementally. --- src/Analyser/NodeScopeResolver.php | 61 +++++++++++++----------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 20d2d492f23..e7e94ad4c53 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4028,33 +4028,14 @@ public function processArgs( $gatheredTypes = []; $gatheredUnpack = false; $gatheredHasName = false; + $gatheredArgTypeByIndex = []; - // Metadata acceptor: drives the per-arg by-ref/variadic matching. Selected - // ONCE from all arg types gathered on the initial scope (mirrors the - // original ParametersAcceptorSelector::selectFromArgs()), so multi-variant - // selection - which depends on the total argument count - is stable across - // the per-arg loop rather than flapping as the prefix grows. - $metadataAcceptor = null; - if ($parametersAcceptors !== []) { - $fastPath = count($parametersAcceptors) === 1; - if ($fastPath) { - $metadataAcceptor = $parametersAcceptors[0]; - } else { - $metadataTypes = []; - $metadataUnpack = false; - $metadataHasName = false; - foreach ($args as $i => $arg) { - $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; - if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { - $argType = $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope); - } else { - $argType = $this->readStoredOrPriceOnDemand($arg->value, $scope); - } - $this->addGatheredArgType($metadataTypes, $metadataUnpack, $metadataHasName, $originalArg, $i, $argType); - } - $metadataAcceptor = $this->selectArgsMetadataAcceptor($args, $metadataTypes, $parametersAcceptors, $namedArgumentsVariants, $metadataHasName, $metadataUnpack, $scope); - } - } + // Metadata acceptor base - NO forward read. The per-argument resolution below picks the + // count-correct variant (the by-ref/variadic STRUCTURE is variant-stable except where it is + // keyed off the argument count, e.g. sscanf - and the count is known structurally) and + // resolves generic parameter types from the args gathered so far; the call's return type + // comes from the post-loop resolved acceptor. + $metadataAcceptor = $parametersAcceptors[0] ?? null; $hasYield = false; $throwPoints = []; @@ -4106,19 +4087,28 @@ public function processArgs( // contribution (a TValue from its return) participates in the final // resolution (see gatherClosureArgType()). $originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; - $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope)); + $gatheredArgTypeByIndex[$i] = $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]); } $argMetadataAcceptor = $metadataAcceptor; if ( $metadataAcceptor !== null - && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor) + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) ) { - // The single metadata acceptor still carries template / late-resolvable parameter - // types (a generic callable(T), a by-ref out-type). Resolve them from the arg types - // gathered SO FAR: closures sort last and by-ref out-params follow the args that pin - // them, so every determining sibling is already processed (single-pass, no forward read). - $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); + // Resolve the acceptor for this argument from the args gathered SO FAR, padded to the + // full argument count with mixed. Closures sort last and by-ref out-params follow the + // args that pin them, so determining siblings are already processed; the mixed pad keeps + // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), + // while processed siblings resolve a generic callable(T) parameter. No forward read. + $paddedTypes = []; + $paddedUnpack = false; + $paddedHasName = false; + foreach ($args as $j => $paddedArg) { + $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); + } + $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); } $parameters = $argMetadataAcceptor?->getParameters(); @@ -4370,7 +4360,8 @@ public function processArgs( } } - $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $exprResult->getTypeForScope($scope)); + $gatheredArgTypeByIndex[$i] = $exprResult->getTypeForScope($scope); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); } if ($assignByReference && $lookForUnset) { @@ -4413,7 +4404,7 @@ public function processArgs( $writebackAcceptor = $metadataAcceptor; if ( $metadataAcceptor !== null - && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor) + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) ) { $writebackAcceptor = $resolvedAcceptor; } From 379b8a734b533356b3b47a6e48dfa460d890023c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 14:58:29 +0200 Subject: [PATCH 176/378] Deduplicate constant-condition vs impossible-check reports via collectors instead of asking the type specifier inline --- .../BooleanAndConstantConditionRule.php | 30 ++++- .../BooleanNotConstantConditionRule.php | 11 +- .../BooleanOrConstantConditionRule.php | 30 ++++- .../ConstantConditionInTraitHelper.php | 11 +- .../ConstantConditionRuleHelper.php | 22 +--- .../DoWhileLoopConstantConditionRule.php | 30 ++++- .../ElseIfConstantConditionRule.php | 11 +- ...FunctionCallConstantConditionCollector.php | 28 ++++ .../FunctionCallConstantConditionHelper.php | 104 +++++++++++++++ .../FunctionCallConstantConditionRule.php | 124 ++++++++++++++++++ .../Comparison/IfConstantConditionRule.php | 11 +- .../ImpossibleCheckTypeFunctionCallRule.php | 3 + .../ImpossibleCheckTypeMethodCallRule.php | 8 +- .../ImpossibleCheckTypeReportedCollector.php | 26 ++++ ...mpossibleCheckTypeStaticMethodCallRule.php | 8 +- .../LogicalXorConstantConditionRule.php | 30 ++++- src/Rules/Comparison/MatchExpressionRule.php | 44 +++++-- .../TernaryOperatorConstantConditionRule.php | 11 +- .../WhileLoopAlwaysFalseConditionRule.php | 11 +- .../WhileLoopAlwaysTrueConditionRule.php | 24 +++- .../BooleanAndConstantConditionRuleTest.php | 51 ++++++- .../BooleanNotConstantConditionRuleTest.php | 46 ++++++- .../BooleanOrConstantConditionRuleTest.php | 46 ++++++- .../DoWhileLoopConstantConditionRuleTest.php | 46 ++++++- .../ElseIfConstantConditionRuleTest.php | 46 ++++++- .../IfConstantConditionRuleTest.php | 109 ++++++++++++++- ...mpossibleCheckTypeFunctionCallRuleTest.php | 1 + ...sibleCheckTypeGenericOverwriteRuleTest.php | 1 + ...sibleCheckTypeMethodCallRuleEqualsTest.php | 1 + .../ImpossibleCheckTypeMethodCallRuleTest.php | 1 + ...sibleCheckTypeStaticMethodCallRuleTest.php | 1 + .../LogicalXorConstantConditionRuleTest.php | 46 ++++++- .../Comparison/MatchExpressionRuleTest.php | 57 +++++++- ...rnaryOperatorConstantConditionRuleTest.php | 46 ++++++- .../WhileLoopAlwaysFalseConditionRuleTest.php | 46 ++++++- .../WhileLoopAlwaysTrueConditionRuleTest.php | 46 ++++++- .../data/constant-condition-function-call.php | 22 ++++ 37 files changed, 1065 insertions(+), 124 deletions(-) create mode 100644 src/Rules/Comparison/FunctionCallConstantConditionCollector.php create mode 100644 src/Rules/Comparison/FunctionCallConstantConditionHelper.php create mode 100644 src/Rules/Comparison/FunctionCallConstantConditionRule.php create mode 100644 src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php create mode 100644 tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php diff --git a/src/Rules/Comparison/BooleanAndConstantConditionRule.php b/src/Rules/Comparison/BooleanAndConstantConditionRule.php index 60e88caa527..2947e6d3eed 100644 --- a/src/Rules/Comparison/BooleanAndConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanAndConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -27,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -87,16 +89,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -140,16 +144,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } if (count($errors) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { @@ -203,4 +209,16 @@ public function processNode( return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/BooleanNotConstantConditionRule.php b/src/Rules/Comparison/BooleanNotConstantConditionRule.php index fe786dd3c18..0052f9f8ab4 100644 --- a/src/Rules/Comparison/BooleanNotConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanNotConstantConditionRule.php @@ -25,6 +25,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -78,6 +79,10 @@ public function processNode( $errorBuilder->identifier(sprintf('booleanNot.always%s', $exprType->getValue() ? 'False' : 'True')); $ruleError = $errorBuilder->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->expr, !$exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->expr, !$exprType->getValue(), $ruleError); return []; @@ -87,7 +92,11 @@ public function processNode( } } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->expr); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->expr); + } return []; } diff --git a/src/Rules/Comparison/BooleanOrConstantConditionRule.php b/src/Rules/Comparison/BooleanOrConstantConditionRule.php index cc9fc93efa1..a010342caa7 100644 --- a/src/Rules/Comparison/BooleanOrConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanOrConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -27,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -87,16 +89,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); } else { $messages[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -140,16 +144,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); } else { $messages[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } if (count($messages) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { @@ -203,4 +209,16 @@ public function processNode( return $messages; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/ConstantConditionInTraitHelper.php b/src/Rules/Comparison/ConstantConditionInTraitHelper.php index 31f70c70cc6..145c48919c1 100644 --- a/src/Rules/Comparison/ConstantConditionInTraitHelper.php +++ b/src/Rules/Comparison/ConstantConditionInTraitHelper.php @@ -27,6 +27,11 @@ public function __construct( { } + private function exprString(Expr $expr): string + { + return sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); + } + /** * @param class-string> $ruleName */ @@ -40,11 +45,10 @@ public function emitNoError( return; } - $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ $ruleName, $scope->getTraitReflection()->getName(), - $exprString, + $this->exprString($expr), null, ]); } @@ -68,11 +72,10 @@ public function emitError( return; } - $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ $ruleName, $scope->getTraitReflection()->getName(), - $exprString, + $this->exprString($expr), $value, $this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr), ]); diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 098421b4a65..5f5bb44c684 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -3,8 +3,6 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -15,14 +13,13 @@ final class ConstantConditionRuleHelper { public function __construct( - private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) { } - private function shouldSkip(Scope $scope, Expr $expr): bool + private function shouldSkip(Expr $expr): bool { if ( $expr instanceof Expr\BinaryOp\Equal @@ -50,25 +47,12 @@ private function shouldSkip(Scope $scope, Expr $expr): bool return true; } - if ( - ( - $expr instanceof FuncCall - || $expr instanceof MethodCall - || $expr instanceof Expr\StaticCall - ) && !$expr->isFirstClassCallable() - ) { - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedTypeFromScope($scope, $expr); - if ($isAlways !== null) { - return true; - } - } - return false; } public function getBooleanType(Scope $scope, Expr $expr): BooleanType { - if ($this->shouldSkip($scope, $expr)) { + if ($this->shouldSkip($expr)) { return new BooleanType(); } @@ -81,7 +65,7 @@ public function getBooleanType(Scope $scope, Expr $expr): BooleanType public function getNativeBooleanType(Scope $scope, Expr $expr): BooleanType { - if ($this->shouldSkip($scope, $expr)) { + if ($this->shouldSkip($expr)) { return new BooleanType(); } diff --git a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php index bff27fcfe20..603d6613fba 100644 --- a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php +++ b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Continue_; @@ -28,6 +29,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -44,23 +46,24 @@ public function getNodeType(): string public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $exprType = $this->helper->getBooleanType($scope, $node->getCond()); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->getCond()); if ($exprType instanceof ConstantBooleanType) { if ($exprType->getValue()) { if ($node->hasYield()) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if (!$statement instanceof Continue_) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } if (!$statement->num instanceof Int_) { continue; } if ($statement->num->value > 1) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } } @@ -68,7 +71,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if ($statement instanceof Break_) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } } @@ -99,6 +102,10 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE ->line($node->getCond()->getStartLine()) ->identifier(sprintf('doWhile.always%s', $exprType->getValue() ? 'True' : 'False')) ->build(); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->getCond(), $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->getCond(), $exprType->getValue(), $ruleError); return []; @@ -107,8 +114,21 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $cond, + bool $isTypeCheckCandidate, + ): void + { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $cond); + } + } + } diff --git a/src/Rules/Comparison/ElseIfConstantConditionRule.php b/src/Rules/Comparison/ElseIfConstantConditionRule.php index 22c19dd1ec0..f4c02a62a79 100644 --- a/src/Rules/Comparison/ElseIfConstantConditionRule.php +++ b/src/Rules/Comparison/ElseIfConstantConditionRule.php @@ -25,6 +25,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -79,6 +80,10 @@ public function processNode( $errorBuilder->identifier(sprintf('elseif.always%s', $exprType->getValue() ? 'True' : 'False')); $ruleError = $errorBuilder->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -88,7 +93,11 @@ public function processNode( } } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/FunctionCallConstantConditionCollector.php b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php new file mode 100644 index 00000000000..fa37dab949c --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php @@ -0,0 +1,28 @@ +>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array}> + */ +final class FunctionCallConstantConditionCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Comparison/FunctionCallConstantConditionHelper.php b/src/Rules/Comparison/FunctionCallConstantConditionHelper.php new file mode 100644 index 00000000000..567632db258 --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionHelper.php @@ -0,0 +1,104 @@ +exprPrinter->printExpr($expr), $expr->getStartLine()); + } + + /** + * Whether the condition is a function/method/static call that the + * ImpossibleCheckType* rules might own. For these the constant-condition + * reporting is deferred to FunctionCallConstantConditionRule, which + * deduplicates against ImpossibleCheckTypeReportedCollector markers. + */ + public function isTypeCheckCandidate(Expr $expr): bool + { + return ( + $expr instanceof FuncCall + || $expr instanceof MethodCall + || $expr instanceof StaticCall + ) && !$expr->isFirstClassCallable(); + } + + /** + * @param class-string> $ruleName + */ + public function emitFunctionCallNoError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + $scope->emitCollectedData(FunctionCallConstantConditionCollector::class, [ + $ruleName, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $this->exprString($expr), + null, + ]); + } + + /** + * @param class-string> $ruleName + */ + public function emitFunctionCallError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + bool $value, + RuleError $ruleError, + ): void + { + if ($ruleError instanceof FixableNodeRuleError) { + throw new ShouldNotHappenException('Fixable errors are not supported by FunctionCallConstantConditionHelper.'); + } + + $scope->emitCollectedData(FunctionCallConstantConditionCollector::class, [ + $ruleName, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $this->exprString($expr), + $value, + $this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr), + ]); + } + + public function emitImpossibleCheckReported( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + $scope->emitCollectedData(ImpossibleCheckTypeReportedCollector::class, [ + $this->exprString($expr), + ]); + } + +} diff --git a/src/Rules/Comparison/FunctionCallConstantConditionRule.php b/src/Rules/Comparison/FunctionCallConstantConditionRule.php new file mode 100644 index 00000000000..1754bd9cdc9 --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionRule.php @@ -0,0 +1,124 @@ + + */ +#[RegisteredRule(level: 4)] +final class FunctionCallConstantConditionRule implements Rule +{ + + private const NULL_TRAIT_KEY = "\0null-trait"; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $reportedMarkers = []; + foreach ($node->get(ImpossibleCheckTypeReportedCollector::class) as $fileData) { + foreach ($fileData as $data) { + $reportedMarkers[$data[0]] = true; + } + } + + $errorsByRuleTraitExprValue = []; + foreach ($node->get(FunctionCallConstantConditionCollector::class) as $fileData) { + foreach ($fileData as $data) { + $ruleName = $data[0]; + $traitName = $data[1]; + $traitKey = $traitName ?? self::NULL_TRAIT_KEY; + $exprString = $data[2]; + $value = $data[3]; + $valueKey = var_export($value, true); + if ($data[3] === null) { + $errorsByRuleTraitExprValue[$ruleName][$traitKey][$exprString][$valueKey][] = null; + // no error reported + continue; + } + + $error = $data[4]; + $errorsByRuleTraitExprValue[$ruleName][$traitKey][$exprString][$valueKey][] = $error; + } + } + + $transformedErrors = []; + foreach ($errorsByRuleTraitExprValue as $ruleData) { + foreach ($ruleData as $traitKey => $traitData) { + $isTrait = $traitKey !== self::NULL_TRAIT_KEY; + foreach ($traitData as $exprString => $valueData) { + if (array_key_exists($exprString, $reportedMarkers)) { + // the ImpossibleCheckType* rule owns this call site + continue; + } + + if ($isTrait && count($valueData) > 1) { + continue; + } + + $uniquedErrors = []; + foreach ($valueData as $errors) { + foreach ($errors as $errorObject) { + if ($errorObject === null) { + continue; + } + if (is_array($errorObject)) { + $errorObject = Error::decode($errorObject); + } + + $message = $errorObject->getMessage(); + $uniquedErrors[$message] = $errorObject; + } + } + + $uniquedErrors = array_values($uniquedErrors); + if (count($uniquedErrors) === 0) { + continue; + } + + if (!$isTrait) { + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + continue; + } + + if (count($uniquedErrors) === 1) { + // report directly in trait, no "in context of" + $transformedErrors[] = new TransformedRuleError($uniquedErrors[0]->removeTraitContext()); + continue; + } + + // report each error in its context + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + } + } + } + + return $transformedErrors; + } + +} diff --git a/src/Rules/Comparison/IfConstantConditionRule.php b/src/Rules/Comparison/IfConstantConditionRule.php index a1eb712e653..5d43388b75f 100644 --- a/src/Rules/Comparison/IfConstantConditionRule.php +++ b/src/Rules/Comparison/IfConstantConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -68,6 +69,10 @@ public function processNode( ))) ->identifier(sprintf('if.always%s', $exprType->getValue() ? 'True' : 'False')) ->line($node->cond->getStartLine())->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -76,7 +81,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index 40dcc9d4e4a..134dafbb3f5 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -25,6 +25,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -56,6 +57,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return []; } + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $funcCall); + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $funcCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php index ee3511d30cc..05d5a76daf2 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -28,6 +28,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -50,6 +51,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE if (!$methodCall->name instanceof Node\Identifier) { return []; } + $methodName = $methodCall->name->name; $reasons = []; $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $methodCall, $nodeResult, $reasons); @@ -58,6 +60,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return []; } + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $methodCall); + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $methodCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); @@ -81,7 +85,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE }; if (!$isAlways) { - $method = $this->getMethod($methodCall->var, $methodCall->name->name, $scope); + $method = $this->getMethod($methodCall->var, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to false.', $method->getDeclaringClass()->getDisplayName(), @@ -103,7 +107,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return []; } - $method = $this->getMethod($methodCall->var, $methodCall->name->name, $scope); + $method = $this->getMethod($methodCall->var, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), diff --git a/src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php b/src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php new file mode 100644 index 00000000000..fb2f9a7b48b --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php @@ -0,0 +1,26 @@ + + */ +final class ImpossibleCheckTypeReportedCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index c1886ed6c39..874636456e8 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -28,6 +28,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -50,6 +51,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE if (!$staticCall->name instanceof Node\Identifier) { return []; } + $methodName = $staticCall->name->name; $reasons = []; $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $staticCall, $nodeResult, $reasons); @@ -58,6 +60,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return []; } + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $staticCall); + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $staticCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); @@ -81,7 +85,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE }; if (!$isAlways) { - $method = $this->getMethod($staticCall->class, $staticCall->name->name, $scope); + $method = $this->getMethod($staticCall->class, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to false.', @@ -104,7 +108,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return []; } - $method = $this->getMethod($staticCall->class, $staticCall->name->name, $scope); + $method = $this->getMethod($staticCall->class, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), diff --git a/src/Rules/Comparison/LogicalXorConstantConditionRule.php b/src/Rules/Comparison/LogicalXorConstantConditionRule.php index 16589f28886..c4566be9ea8 100644 --- a/src/Rules/Comparison/LogicalXorConstantConditionRule.php +++ b/src/Rules/Comparison/LogicalXorConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\LogicalXor; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; @@ -26,6 +27,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -77,16 +79,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } $ruleError = $errorBuilder->build(); - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->left, $leftType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); + $this->emitNoError($scope, $node->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); + $this->emitNoError($scope, $node->left); } $rightType = $this->helper->getBooleanType($scope, $node->right); @@ -124,19 +128,33 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } $ruleError = $errorBuilder->build(); - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->right, $rightType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); + $this->emitNoError($scope, $node->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); + $this->emitNoError($scope, $node->right); } return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/MatchExpressionRule.php b/src/Rules/Comparison/MatchExpressionRule.php index d89fe2dd87c..4757493d62f 100644 --- a/src/Rules/Comparison/MatchExpressionRule.php +++ b/src/Rules/Comparison/MatchExpressionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -34,6 +35,7 @@ public function __construct( private ConstantConditionRuleHelper $constantConditionRuleHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -67,14 +69,16 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } foreach ($armConditions as $armCondition) { $armConditionScope = $armCondition->getScope(); + $rawCondition = $armCondition->getCondition(); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($rawCondition); $armConditionExpr = new Node\Expr\BinaryOp\Identical( $matchCondition, - $armCondition->getCondition(), + $rawCondition, ); $armConditionResult = $armConditionScope->getType($armConditionExpr); if (!$armConditionResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } if ($armConditionResult->getValue()) { @@ -84,7 +88,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE if (!$this->treatPhpDocTypesAsCertain) { $armConditionNativeResult = $armConditionScope->getNativeType($armConditionExpr); if (!$armConditionNativeResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } if ($armConditionNativeResult->getValue()) { @@ -93,9 +97,9 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } if ($matchConditionType instanceof ConstantBooleanType) { - $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $armCondition->getCondition()); + $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $rawCondition); if (!$armConditionStandaloneResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } } @@ -105,11 +109,15 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder = RuleErrorBuilder::message(sprintf( 'Match arm comparison between %s and %s is always false.', $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), - $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + $armConditionScope->getType($rawCondition)->describe(VerbosityLevel::value()), ))->line($armLine)->identifier('match.alwaysFalse'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); $ruleError = $errorBuilder->build(); - if ($scope->isInTrait()) { + if ($isTypeCheckCandidate) { + // the constant-ness of a type-check call is owned by the + // ImpossibleCheckType* rules; defer and deduplicate against them + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $rawCondition, false, $ruleError); + } elseif ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, false, $ruleError); } else { $errors[] = $ruleError; @@ -118,14 +126,14 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } if ($i === $armsCount - 1) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } $message = sprintf( 'Match arm comparison between %s and %s is always true.', $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), - $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + $armConditionScope->getType($rawCondition)->describe(VerbosityLevel::value()), ); $errorBuilder = RuleErrorBuilder::message($message) ->line($armLine) @@ -133,7 +141,9 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE ->tip('Remove remaining cases below this one and this error will disappear too.'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); $ruleError = $errorBuilder->build(); - if ($scope->isInTrait()) { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $rawCondition, true, $ruleError); + } elseif ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, true, $ruleError); } else { $errors[] = $ruleError; @@ -167,6 +177,20 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $armConditionExpr, + Expr $rawCondition, + bool $isTypeCheckCandidate, + ): void + { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $rawCondition); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + } + } + private function isUnhandledMatchErrorCaught(Node $node): bool { $tryCatchTypes = $node->getAttribute(TryCatchTypeVisitor::ATTRIBUTE_NAME); diff --git a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php index ddb606965c9..d1732bcb416 100644 --- a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php +++ b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -65,6 +66,10 @@ public function processNode( 'Ternary operator condition is always %s.', $exprType->getValue() ? 'true' : 'false', )))->identifier(sprintf('ternary.always%s', $exprType->getValue() ? 'True' : 'False'))->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -73,7 +78,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php index d6bd7479dc8..4859e0270cb 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -65,6 +66,10 @@ public function processNode( $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always false.'))->line($node->cond->getStartLine()) ->identifier('while.alwaysFalse') ->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, false, $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, false, $ruleError); return []; @@ -73,7 +78,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php index ef942cfe0cf..2acdc51da6b 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php @@ -28,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -71,16 +72,25 @@ public function processNode( } $originalNode = $node->getOriginalNode(); $exprType = $this->helper->getBooleanType($scope, $originalNode->cond); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->cond); if ($exprType->isTrue()->yes()) { if ($node->hasYield()) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } $ref = $scope->getFunction() ?? $scope->getAnonymousFunctionReflection(); if ($ref !== null && $ref->getReturnType() instanceof NeverType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } @@ -105,6 +115,10 @@ public function processNode( $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always true.'))->line($originalNode->cond->getStartLine()) ->identifier('while.alwaysTrue') ->build(); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->cond, true, $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->cond, true, $ruleError); return []; @@ -113,7 +127,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index fea58e7d61d..9967649a4c8 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -24,19 +24,55 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanAndConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -354,6 +390,11 @@ public static function dataBug4969(): iterable { yield [false, []]; yield [true, [ + [ + 'Call to function is_string() with string will always evaluate to true.', + 12, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], [ 'Result of && is always false.', 15, diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index e2121b4b7fc..5e5cbe633d2 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -23,19 +23,55 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanNotConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index ba4aab80505..c32ef7f10e3 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -24,19 +24,55 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanOrConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index 6a6269ae7c0..bde9382cef4 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -19,18 +19,54 @@ protected function getRule(): Rule return new CompositeRule([ new DoWhileLoopConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 2683e22f403..766f6d1aa77 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -24,19 +24,55 @@ protected function getRule(): Rule return new CompositeRule([ new ElseIfConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 37a532cb07c..96b20b07bd1 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -21,18 +21,54 @@ protected function getRule(): Rule return new CompositeRule([ new IfConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -55,6 +91,10 @@ public function testRule(): void 'If condition is always false.', 45, ], + [ + 'Call to function is_object() with object will always evaluate to true.', + 93, + ], [ 'If condition is always true.', 96, @@ -114,6 +154,11 @@ public function testBug4043(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-4043.php'], [ + [ + 'Call to function assert() with true will always evaluate to true.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], [ 'If condition is always false.', 43, @@ -257,6 +302,22 @@ public function testBug6211(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6211.php'], [ + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 34, + ], + [ + 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'test\' will always evaluate to true.', + 39, + ], + [ + 'Call to function method_exists() with Bug6211\Bar and \'realMethod\' will always evaluate to true.', + 62, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 87, + ], [ 'If condition is always true.', 93, @@ -265,10 +326,31 @@ public function testBug6211(): void 'If condition is always true.', 100, ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 106, + ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 107, + ], [ 'If condition is always true.', 114, ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 120, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 121, + ], + [ + 'Call to function method_exists() with class-string and \'test\' will always evaluate to true.', + 136, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } @@ -289,4 +371,21 @@ public function testBug8980(): void ]); } + public function testConstantConditionFunctionCall(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/constant-condition-function-call.php'], [ + [ + // type-check call: owned by the ImpossibleCheckType rule, reported only once + 'Call to function is_int() with int will always evaluate to true.', + 13, + ], + [ + // non-type-check, always-truthy return: reported by the constant-condition rule + 'If condition is always true.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5f357bd51b4..4bbb387ecbb 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -34,6 +34,7 @@ protected function getRule(): Rule ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index fae1bdb6e11..b706d1569f8 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -21,6 +21,7 @@ public function getRule(): Rule ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php index 6d7e5d9f2ed..f2b07c46b6e 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -21,6 +21,7 @@ public function getRule(): Rule ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 9dde818f42c..27c4bbac60a 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -30,6 +30,7 @@ public function getRule(): Rule ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1846fd0cac3..1a479ec9784 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -30,6 +30,7 @@ public function getRule(): Rule ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index 158350103ea..a64a549b5ba 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -19,19 +19,55 @@ protected function getRule(): TRule return new CompositeRule([ new LogicalXorConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), false, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index ae37a243f9d..844d145d8a2 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -21,17 +21,53 @@ protected function getRule(): Rule return new CompositeRule([ new MatchExpressionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -307,7 +343,16 @@ public function testBug8932(): void public function testBug8937(): void { $this->treatPhpDocTypesAsCertain = false; - $this->analyse([__DIR__ . '/data/bug-8937.php'], []); + $this->analyse([__DIR__ . '/data/bug-8937.php'], [ + [ + 'Call to function is_array() with array will always evaluate to true.', + 23, + ], + [ + 'Call to function is_array() with non-empty-array will always evaluate to true.', + 24, + ], + ]); } #[RequiresPhp('>= 8.0.0')] diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index cda724eec1c..dce53141a33 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -21,18 +21,54 @@ protected function getRule(): Rule return new CompositeRule([ new TernaryOperatorConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index 93403a0a514..e05b9254a39 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -19,18 +19,54 @@ protected function getRule(): Rule return new CompositeRule([ new WhileLoopAlwaysFalseConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php index bb9fd1499ff..4dc0cfba24e 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -19,18 +19,54 @@ protected function getRule(): Rule return new CompositeRule([ new WhileLoopAlwaysTrueConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->getTypeSpecifier(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php b/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php new file mode 100644 index 00000000000..af99fff7e93 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php @@ -0,0 +1,22 @@ + Date: Thu, 25 Jun 2026 16:18:02 +0200 Subject: [PATCH 177/378] Process a dynamic function-call name before reading its type FuncCallHandler read $scope->getType($expr->name) for a dynamic callee (e.g. $fn()) before processExprNode() had processed and stored $expr->name - an NW-guard violation. Process the name first and consume its ExpressionResult, mirroring the single-pass inside-out invariant (handlers consume results, never getType an unprocessed node). --- src/Analyser/ExprHandler/FuncCallHandler.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 10a56ee5af4..084d2bc8f24 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -123,7 +123,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; if ($expr->name instanceof Expr) { - $nameType = $scope->getType($expr->name); + // process the dynamic callee name first, then consume its type (single-pass + // inside-out) rather than reading it before processExprNode() stores it + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $nameType = $nameResult->getTypeForScope($scope); if (!$nameType->isCallable()->no()) { $variants = $nameType->getCallableParametersAcceptors($scope); // A structural acceptor (names/positions/variadic) drives the per-arg @@ -132,7 +135,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, null); } - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $nameResult->getScope(); $throwPoints = $nameResult->getThrowPoints(); $impurePoints = $nameResult->getImpurePoints(); From e13d727c72699c7946b55b585f005abd7b0a256a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 16:27:29 +0200 Subject: [PATCH 178/378] Process a nested array-dimension before reading its type in AssignHandler The nested-dim assignment loop read $dimExpr's type via readStoredOrPriceOnDemand before processExprNode() had processed it - an NW-guard violation. Process the dimension first and consume its ExpressionResult (the lazy dim-fetch typeCallback, which also reads $dimExpr, is now stored after the dimension is processed). --- src/Analyser/ExprHandler/AssignHandler.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index c5c59da589c..d61d4efd7f1 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -642,12 +642,17 @@ public function processAssignVar( )); } else { - $offsetTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope), $dimFetch]; - $offsetNativeTypes[] = [$nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope), $dimFetch]; - if ($enterExpressionAssign) { $scope->enterExpressionAssign($dimExpr); } + // process the dimension first, then consume its ExpressionResult + // (single-pass inside-out) rather than reading it before processExprNode() + $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $offsetTypes[] = [$result->getTypeForScope($scope), $dimFetch]; + $offsetNativeTypes[] = [$result->getNativeTypeForScope($scope), $dimFetch]; + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -659,9 +664,6 @@ public function processAssignVar( typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $s)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $s)), specifyTypesCallback: static fn () => new SpecifiedTypes(), )); - $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $hasYield || $result->hasYield(); - $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $scope = $result->getScope(); if ($enterExpressionAssign) { From fd0cb911b35cfbb9ec9635eb5ff53203efe207cc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 18:59:50 +0200 Subject: [PATCH 179/378] Process an assign-op Variable/property target as a read before composing its type AssignOpHandler's typeCallback resolves `$lvalue OP= $expr` by reading the old value of `$lvalue` through InitializerExprTypeResolver. processAssignVar() processes a Variable/property target only as an assignment target, never as a read, so the lvalue's ExpressionResult was never stored - the typeCallback then priced the unprocessed node on demand (an NW-guard violation, e.g. `$a += 1`, `$this->p += 1`, `self::$s |= 2`). Process the target as a read first (NoopNodeCallback, since processAssignVar already presents it to the node callback) so the typeCallback consumes its stored result. An ArrayDimFetch target is already stored by processAssignVar, so it is left out; Coalesce (??=) reads its left side through its own path. --- src/Analyser/ExprHandler/AssignOpHandler.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 1c328a1b2ee..f50c4e1b0a7 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -20,6 +20,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -60,6 +61,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; + if ( + !$expr instanceof Expr\AssignOp\Coalesce + && ($expr->var instanceof Expr\Variable + || $expr->var instanceof Expr\PropertyFetch + || $expr->var instanceof Expr\StaticPropertyFetch) + ) { + // `$lvalue OP= ...` reads the old value of `$lvalue`; processAssignVar() + // processes a Variable/property target only as an assignment target, never + // the whole lvalue as a read, so it never stores its ExpressionResult. + // Process it here as a read so the typeCallback below consumes the stored + // result instead of pricing the unprocessed lvalue on demand (single-pass + // inside-out). The NoopNodeCallback avoids duplicate reports: + // processAssignVar() already presents the target (and its sub-expressions) + // to the node callback. (An ArrayDimFetch target is stored by + // processAssignVar itself, so it is left out here.) + $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + } + $typeCallback = function (MutatingScope $s) use ($expr, $nodeScopeResolver, $beforeScope): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the From 05cb7252d3b0ed12617ce56339cccd0da0e63a80 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 19:09:53 +0200 Subject: [PATCH 180/378] Process Closure::call's new-$this argument before reading its type MethodCallHandler resolved the bind scope for `(function () {...})->call($newThis)` by reading $newThis through Scope::getType() before processExprNode() had processed it - an NW-guard violation. Process the argument as a read first and consume its ExpressionResult for enterClosureCall(); processArgs() still processes it as call()'s first argument, so the read here uses a NoopNodeCallback to avoid a duplicate node-callback. --- src/Analyser/ExprHandler/MethodCallHandler.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 8e3008b01d7..2d78de8a6c0 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -21,6 +21,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -83,9 +84,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && strtolower($expr->name->name) === 'call' && isset($expr->getArgs()[0]) ) { + // process the new-$this argument as a read so enterClosureCall() consumes + // its stored ExpressionResult instead of reading the unprocessed node via + // Scope::getType(). processArgs() below processes it again as call()'s first + // argument; the NoopNodeCallback here avoids a duplicate node-callback. + $newThisResult = $nodeScopeResolver->processExprNode($stmt, $expr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $closureCallScope = $scope->enterClosureCall( - $scope->getType($expr->getArgs()[0]->value), - $scope->getNativeType($expr->getArgs()[0]->value), + $newThisResult->getTypeForScope($scope), + $newThisResult->getNativeTypeForScope($scope), ); } From 536392d2ee53af12984fa2828d5fc8ee1dd63556 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 25 Jun 2026 19:15:18 +0200 Subject: [PATCH 181/378] Process clone()'s arguments before reading them in clone-with handling The PHP 8.5 clone-with branch read the cloned object and the properties array through Scope::getType() before processExprNode() had processed them - an NW-guard violation. Process both arguments as reads first and consume the properties-array ExpressionResult; processArgs() processes them again as clone()'s arguments, so the reads here use a NoopNodeCallback to avoid duplicate node-callbacks. --- src/Analyser/ExprHandler/FuncCallHandler.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 084d2bc8f24..8aaf70a8f1f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -190,7 +190,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $functionReflection->getName() === 'clone' && count($normalizedExpr->getArgs()) === 2 ) { - $clonePropertiesArgType = $scope->getType($normalizedExpr->getArgs()[1]->value); + // process the clone arguments as reads so the cloned object and the + // properties array resolve from stored results instead of unprocessed + // nodes; processArgs() below processes them again as clone()'s arguments, + // so the NoopNodeCallback here avoids duplicate node-callbacks. + $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $clonePropertiesArgType = $clonePropertiesArgResult->getTypeForScope($scope); $cloneExpr = new TypeExpr($scope->getType(new Expr\Clone_($normalizedExpr->getArgs()[0]->value))); $clonePropertiesArgTypeConstantArrays = $clonePropertiesArgType->getConstantArrays(); foreach ($clonePropertiesArgTypeConstantArrays as $clonePropertiesArgTypeConstantArray) { From 4f470743698aab186664f953aa5318f1ebec209d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 10:51:36 +0200 Subject: [PATCH 182/378] Evaluate an array-dim assignment's value before reading its type The ArrayDimFetch assignment branch read getType($assignedExpr)/getNativeType() before processExprNode() had evaluated the assigned expression, so the AssignOp typeCallback (and a plain assign's RHS) priced unprocessed operand nodes on demand - an NW-guard violation that a stale comment defended as load-bearing for bug-13623. Evaluate the assigned expression first and read its value on the captured pre-eval scope, mirroring the Variable branch. The `$x[...] ??= []` optional array{} branch is preserved by the coalesce typeCallback carrying the isset descriptor (since "Carry the ??= left side's isset descriptor into its coalesce type"), not by reading a resolvedTypes cache, so bug-13623 still passes. --- src/Analyser/ExprHandler/AssignHandler.php | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index d61d4efd7f1..895eb2367a9 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -672,20 +672,13 @@ public function processAssignVar( } } - // SKIPPED (single-pass inside-out invariant): these two reads must stay as - // Scope::getType()/getNativeType(). This is NOT a scope-state side effect - // (assignExpression cannot reproduce it): getType() returns its cached - // resolvedTypes value, computed during loop convergence when a `$x[...] ??= - // []` left side was still maybe-set, so the coalesced value keeps its - // optional array{} branch. The side-effect-free helpers re-price on the - // converged (definitely-set) scope, where CoalesceHandler drops the array{} - // branch (issetCheck === true) - which regresses bug-13623. The optionality - // lives in the loop history the converged scope no longer carries. - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); + // 3. eval assigned expr first, then read the assigned value on the pre-eval + // scope - so the read consumes the now-stored result of $assignedExpr (and + // of its operands) instead of pricing unprocessed nodes (mirrors the + // Variable branch above). The ??= left side's optional array{} branch is + // preserved by the coalesce typeCallback carrying the isset descriptor, not + // by reading a stale resolvedTypes cache (bug-13623). $scopeBeforeAssignEval = $scope; - - // 3. eval assigned expr $result = $processExprCallback($scope); $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -693,6 +686,9 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); + $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scopeBeforeAssignEval); + $varType = $varResult->getTypeForScope($scope); $varNativeType = $varResult->getNativeTypeForScope($scope); From 82681661b9ed7dc73bbdec0585929e7b213b0d3d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 11:09:10 +0200 Subject: [PATCH 183/378] Read the foreach value variable's narrowed type by name in the loop array rewrite The loop array-rewrite read the value variable's narrowed type via readStoredOrPriceOnDemand($stmt->valueVar) - but the value variable is assigned by the foreach, never processed by processExprNode(), so this priced an unprocessed node on demand (an NW-guard violation). Read it by name with getVariableType() instead, which consumes its tracked type directly. --- src/Analyser/NodeScopeResolver.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e7e94ad4c53..f3db3de9aa6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1730,11 +1730,16 @@ public function processStmtNode( // the narrowed value-var type in place of the broader dim fetch type so // the loop's final array rewrite below picks up the sharper element type. if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { - $valueVarType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType); + // read the loop value variable's narrowed type directly by name - + // it is an assigned (not processExprNode-processed) variable, so + // getVariableType() consumes its tracked type without pricing the + // unprocessed node on demand. ($originalValueExpr !== null implies + // the value var is a string-named Variable.) + $valueVarType = $scopeWithIterableValueType->getVariableType($stmt->valueVar->name); if ($dimFetchType->isSuperTypeOf($valueVarType)->yes()) { $dimFetchType = $valueVarType; } - $valueVarNativeType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); + $valueVarNativeType = $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()->getVariableType($stmt->valueVar->name); if ($dimFetchNativeType->isSuperTypeOf($valueVarNativeType)->yes()) { $dimFetchNativeType = $valueVarNativeType; } @@ -1742,9 +1747,12 @@ public function processStmtNode( $keyLoopTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); $keyLoopNativeTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); } else { - // No key variable: the narrowed value var is the array element type directly. - $dimFetchType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType); - $dimFetchNativeType = $this->readStoredOrPriceOnDemand($stmt->valueVar, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); + // No key variable: the narrowed value var is the array element type + // directly. Read it by name (assigned, not processExprNode-processed); + // no key var implies $originalValueExpr !== null, so the value var is + // a string-named Variable. + $dimFetchType = $scopeWithIterableValueType->getVariableType($stmt->valueVar->name); + $dimFetchNativeType = $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()->getVariableType($stmt->valueVar->name); } $arrayDimFetchLoopTypes[] = $dimFetchType; $arrayDimFetchLoopNativeTypes[] = $dimFetchNativeType; From 5b8d9dbd684d4ef969f229996bb5e1062d85914f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 11:45:08 +0200 Subject: [PATCH 184/378] Carry the nullsafe receiver type to its rule via a virtual node NullsafePropertyFetchRule and NullsafeMethodCallRule read the receiver type with Scope::getScopeType($node->var) - a non-suspending FiberScope read that reaches the unprocessed receiver before the handler processes it. Emit a Nullsafe{Property,Method}CallExpressionNode from the handler carrying the receiver's (possibly null) type, and have the rules read it from there. A step toward removing getScopeType()/getScopeNativeType(). --- .../ExprHandler/NullsafeMethodCallHandler.php | 5 ++ .../NullsafePropertyFetchHandler.php | 5 ++ src/Node/NullsafeMethodCallExpressionNode.php | 59 ++++++++++++++++++ .../NullsafePropertyFetchExpressionNode.php | 60 +++++++++++++++++++ src/Rules/Methods/NullsafeMethodCallRule.php | 14 +++-- .../Properties/NullsafePropertyFetchRule.php | 16 ++--- 6 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 src/Node/NullsafeMethodCallExpressionNode.php create mode 100644 src/Node/NullsafePropertyFetchExpressionNode.php diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 4caac12bab5..ac79232ca32 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -22,6 +22,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\NullsafeMethodCallExpressionNode; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -57,6 +58,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + // carry the receiver type to NullsafeMethodCallRule so it reads it from here + // instead of asking the scope for the unprocessed receiver. + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 25cb38a0099..c3866f7d517 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -22,6 +22,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\NullsafePropertyFetchExpressionNode; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -55,6 +56,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + // carry the receiver type to NullsafePropertyFetchRule so it reads it from + // here instead of asking the scope for the unprocessed receiver. + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); diff --git a/src/Node/NullsafeMethodCallExpressionNode.php b/src/Node/NullsafeMethodCallExpressionNode.php new file mode 100644 index 00000000000..bf8aa01c3e1 --- /dev/null +++ b/src/Node/NullsafeMethodCallExpressionNode.php @@ -0,0 +1,59 @@ +getAttributes()); + } + + public function getOriginalNode(): NullsafeMethodCall + { + return $this->originalNode; + } + + public function getCalledOnType(): Type + { + return $this->calledOnType; + } + + public function getCalledOnNativeType(): Type + { + return $this->calledOnNativeType; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_NullsafeMethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/NullsafePropertyFetchExpressionNode.php b/src/Node/NullsafePropertyFetchExpressionNode.php new file mode 100644 index 00000000000..d58ba8c7db9 --- /dev/null +++ b/src/Node/NullsafePropertyFetchExpressionNode.php @@ -0,0 +1,60 @@ +getAttributes()); + } + + public function getOriginalNode(): NullsafePropertyFetch + { + return $this->originalNode; + } + + public function getCalledOnType(): Type + { + return $this->calledOnType; + } + + public function getCalledOnNativeType(): Type + { + return $this->calledOnNativeType; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_NullsafePropertyFetchExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Rules/Methods/NullsafeMethodCallRule.php b/src/Rules/Methods/NullsafeMethodCallRule.php index a3d3b872f73..e77fa140bec 100644 --- a/src/Rules/Methods/NullsafeMethodCallRule.php +++ b/src/Rules/Methods/NullsafeMethodCallRule.php @@ -6,13 +6,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\NullsafeMethodCallExpressionNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class NullsafeMethodCallRule implements Rule @@ -29,22 +30,23 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\NullsafeMethodCall::class; + return NullsafeMethodCallExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); + $originalNode = $node->getOriginalNode(); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $node->getCalledOnType() : $node->getCalledOnNativeType(); if (!$calledOnType->isNull()->no()) { return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($node): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { return $ruleErrorBuilder; } - $calledOnNativeType = $scope->getScopeNativeType($node->var); + $calledOnNativeType = $node->getCalledOnNativeType(); if ($calledOnNativeType->isNull()->no()) { return $ruleErrorBuilder; } @@ -58,7 +60,7 @@ public function processNode(Node $node, Scope $scope): array $calledOnType->describe(VerbosityLevel::typeOnly()), )), ) - ->line($node->name->getStartLine()) + ->line($originalNode->name->getStartLine()) ->identifier('nullsafe.neverNull'); return [$ruleErrorBuilder->build()]; diff --git a/src/Rules/Properties/NullsafePropertyFetchRule.php b/src/Rules/Properties/NullsafePropertyFetchRule.php index 1e11e03f8a8..debad865a30 100644 --- a/src/Rules/Properties/NullsafePropertyFetchRule.php +++ b/src/Rules/Properties/NullsafePropertyFetchRule.php @@ -6,13 +6,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\NullsafePropertyFetchExpressionNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class NullsafePropertyFetchRule implements Rule @@ -29,26 +30,27 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\NullsafePropertyFetch::class; + return NullsafePropertyFetchExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); + $originalNode = $node->getOriginalNode(); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $node->getCalledOnType() : $node->getCalledOnNativeType(); if (!$calledOnType->isNull()->no()) { return []; } - if ($scope->isUndefinedExpressionAllowed($node)) { + if ($scope->isUndefinedExpressionAllowed($originalNode)) { return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($node): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { return $ruleErrorBuilder; } - $calledOnNativeType = $scope->getScopeNativeType($node->var); + $calledOnNativeType = $node->getCalledOnNativeType(); if ($calledOnNativeType->isNull()->no()) { return $ruleErrorBuilder; } @@ -62,7 +64,7 @@ public function processNode(Node $node, Scope $scope): array $calledOnType->describe(VerbosityLevel::typeOnly()), )), ) - ->line($node->name->getStartLine()) + ->line($originalNode->name->getStartLine()) ->identifier('nullsafe.neverNull'); return [$ruleErrorBuilder->build()]; From c3bc1ffa8511566a8353922626a8796286e9694f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 18:00:27 +0200 Subject: [PATCH 185/378] Drop the dead Expr parameter from ExpressionResult's typeCallback The type callback was typed callable(MutatingScope, Expr): Type, but no handler's closure ever bound the second parameter - ExpressionResult invoked it as ($scope, $this->expr) at all four sites and PHP silently discarded the extra argument. Remove it from the typedef (property + factory) and stop passing $this->expr, so the callback is callable(MutatingScope): Type. No behaviour change; the first step toward making the callback scope-keyed and memoizable. --- src/Analyser/ExpressionResult.php | 12 ++++++------ src/Analyser/ExpressionResultFactory.php | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 415ce2590e8..66ca22ea6ce 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -13,7 +13,7 @@ final class ExpressionResult { - /** @var (callable(MutatingScope, Expr): Type)|null */ + /** @var (callable(MutatingScope): Type)|null */ private $typeCallback; /** @var (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ @@ -39,7 +39,7 @@ final class ExpressionResult /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints - * @param (callable(MutatingScope, Expr): Type)|null $typeCallback + * @param (callable(MutatingScope): Type)|null $typeCallback * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback @@ -206,7 +206,7 @@ public function getType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { - return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope, $this->expr)); + return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope)); } return $this->cachedType = $this->beforeScope->getType($this->expr); @@ -223,7 +223,7 @@ public function getNativeType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { - return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain(), $this->expr)); + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain())); } return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); @@ -305,7 +305,7 @@ public function getTypeForScope(MutatingScope $scope): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope, $this->expr)); + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope)); } return $scope->getType($this->expr); @@ -320,7 +320,7 @@ public function getNativeTypeForScope(MutatingScope $scope): Type $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope, $this->expr)); + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope)); } return $scope->getNativeType($this->expr); diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 94cbb846b5d..341103d6e68 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -13,7 +13,7 @@ interface ExpressionResultFactory * @param ImpurePoint[] $impurePoints * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback - * @param (callable(MutatingScope, Expr): Type)|null $typeCallback + * @param (callable(MutatingScope): Type)|null $typeCallback * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ From 3fce90cc59d3e0ce2d65f266e8d12323b32b3020 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 18:18:07 +0200 Subject: [PATCH 186/378] Build pre-inc/dec literal types via ConstantTypeHelper, not the scope The pre-increment and pre-decrement type callbacks built each stepped literal type with $s->getTypeFromValue($varValue), which only delegates to the scope-free ConstantTypeHelper::getTypeFromValue(). Call the helper directly so the callback no longer reads the scope for this (the value comes from the child result's constant scalars). Removes the only genuine scope read in both callbacks. --- src/Analyser/ExprHandler/PreDecHandler.php | 3 ++- src/Analyser/ExprHandler/PreIncHandler.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index b21eb79a145..d5d2fead59f 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -22,6 +22,7 @@ use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -79,7 +80,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex --$varValue; } - $newTypes[] = $s->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 5391bffb825..73e97b577f8 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -22,6 +22,7 @@ use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -80,7 +81,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ++$varValue; } - $newTypes[] = $s->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { From 9676d5c98031b835d6667ff1956275f9a57bdb3e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 18:25:59 +0200 Subject: [PATCH 187/378] Resolve lexical context once at create()-time in three type callbacks The class-const-fetch, yield and scalar type callbacks read lexically-fixed context off the callback's scope: the enclosing class reflection (ClassConstFetch), the enclosing function reflection (Yield), and the InitializerExprContext (Scalar). None of these vary with the (possibly narrowed) scope the callback is later invoked on, so resolve them once in processExpr() and capture them into the closure. ClassConstFetch keeps its scope parameter only to forward into the class child result; Yield and Scalar no longer read the scope at all. --- src/Analyser/ExprHandler/ClassConstFetchHandler.php | 9 +++++++-- src/Analyser/ExprHandler/ScalarHandler.php | 7 +++++-- src/Analyser/ExprHandler/YieldHandler.php | 7 +++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 2ce737b9db7..890224055be 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -73,6 +73,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } + // the enclosing class is lexical - fixed at this node, identical on every + // (possibly narrowed) scope the callback may later be invoked with - so + // resolve it once here instead of reading it off the callback's scope. + $classReflection = $beforeScope->isInClass() ? $beforeScope->getClassReflection() : null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -81,7 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr, $classResult): Type { + typeCallback: function (MutatingScope $scope) use ($expr, $classResult, $classReflection): Type { if (!$expr->name instanceof Identifier) { return new MixedType(); } @@ -89,7 +94,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( $expr->class, $expr->name->name, - $scope->isInClass() ? $scope->getClassReflection() : null, + $classReflection, // getClassConstFetchTypeByReflection only invokes this for $expr->class // when it is an Expr, which is exactly when $classResult exists static function (Expr $e) use ($classResult, $scope): Type { diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index fbefe70d320..a27e0f04ec4 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -14,7 +14,6 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -40,6 +39,10 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + // a literal's type and its initializer context (file/namespace/class) are + // lexical - identical on every scope - so build the context once here. + $initializerExprContext = InitializerExprContext::fromScope($scope); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -48,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: fn (Scope $scope) => $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($scope)), + typeCallback: fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 6ec1d13fc57..1c3cc3ec445 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -73,6 +73,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating(); } + // the enclosing function is lexical - the generator TSend type does not + // vary with the scope the callback is later invoked on - resolve it once here. + $functionReflection = $beforeScope->getFunction(); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -81,8 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static function (MutatingScope $scope): Type { - $functionReflection = $scope->getFunction(); + typeCallback: static function () use ($functionReflection): Type { if ($functionReflection === null) { return new MixedType(); } From 1515c80c88facc9274ae4b05e5df85ef7d8f2d54 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 19:31:57 +0200 Subject: [PATCH 188/378] Resolve the instanceof Name class type once at create()-time InstanceofHandler's type and specify callbacks resolved the class side from the callback's scope when it is written as a Name: $s->isInTrait(), and self/static/ parent/resolved-name via $s->isInClass()/getClassReflection()/resolveName(). The class side of a `$x instanceof Name` is lexical and does not vary with the scope the callbacks are later invoked on, so resolve the boolean-result class type and the narrowing type once in processExpr() and capture them. The callbacks now read the scope only to forward into the expr/class child results and createSubjectTypes(). --- .../ExprHandler/InstanceofHandler.php | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 1ada2bd80b7..8e3e5c664d6 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -70,6 +70,40 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $classResult->isAlwaysTerminating(); } + // When the class side is written as a Name (self / static / parent / a + // resolved class name) it is lexical - it does not vary with the scope the + // callbacks are later invoked on - so resolve the boolean-result class type + // and the narrowing type once here. $isInTrait is likewise lexical. + $isInTrait = $beforeScope->isInTrait(); + $nameClassType = null; + $nameNarrowType = null; + if ($expr->class instanceof Name) { + if (strtolower($expr->class->toString()) === 'static' && $beforeScope->isInClass()) { + $nameClassType = new StaticType($beforeScope->getClassReflection()); + } else { + $nameClassType = new ObjectType($beforeScope->resolveName($expr->class)); + } + + $className = (string) $expr->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $beforeScope->isInClass()) { + $nameNarrowType = new ObjectType($beforeScope->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $beforeScope->isInClass()) { + $nameNarrowType = new StaticType($beforeScope->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $beforeScope->isInClass() + && $beforeScope->getClassReflection()->getParentClass() !== null + ) { + $nameNarrowType = new ObjectType($beforeScope->getClassReflection()->getParentClass()->getName()); + } else { + $nameNarrowType = new NonexistentParentClassType(); + } + } else { + $nameNarrowType = new ObjectType($className); + } + } + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -78,10 +112,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $classResult): Type { + typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $classResult, $isInTrait, $nameClassType): Type { $expressionType = $exprResult->getTypeForScope($s); if ( - $s->isInTrait() + $isInTrait && TypeUtils::findThisType($expressionType) !== null ) { return new BooleanType(); @@ -93,16 +127,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $uncertainty = false; if ($expr->class instanceof Name) { - $unresolvedClassName = $expr->class->toString(); - if ( - strtolower($unresolvedClassName) === 'static' - && $s->isInClass() - ) { - $classType = new StaticType($s->getClassReflection()); - } else { - $className = $s->resolveName($expr->class); - $classType = new ObjectType($className); + if ($nameClassType === null) { + throw new ShouldNotHappenException(); } + $classType = $nameClassType; } else { // this branch is only reached when $expr->class is an Expr, // which is exactly when $classResult was set in processExpr @@ -129,28 +157,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $classResult): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $classResult, $nameNarrowType): SpecifiedTypes { $exprNode = $expr->expr; if ($expr->class instanceof Name) { - $className = (string) $expr->class; - $lowercasedClassName = strtolower($className); - if ($lowercasedClassName === 'self' && $s->isInClass()) { - $type = new ObjectType($s->getClassReflection()->getName()); - } elseif ($lowercasedClassName === 'static' && $s->isInClass()) { - $type = new StaticType($s->getClassReflection()); - } elseif ($lowercasedClassName === 'parent') { - if ( - $s->isInClass() - && $s->getClassReflection()->getParentClass() !== null - ) { - $type = new ObjectType($s->getClassReflection()->getParentClass()->getName()); - } else { - $type = new NonexistentParentClassType(); - } - } else { - $type = new ObjectType($className); + if ($nameNarrowType === null) { + throw new ShouldNotHappenException(); } - return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $nameNarrowType, $context)->setRootExpr($expr); } // this branch is only reached when $expr->class is an Expr, From 3a7f38102599de6a257f7b464267978c659b4c53 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 19:47:03 +0200 Subject: [PATCH 189/378] Reprocess the switch subject for the exhaustiveness check instead of re-running its result on a foreign scope The switch exhaustiveness check read the subject's result on $scopeForBranches - the subject narrowed by "none of the cases matched" - which is a genuinely different scope than the subject's own. Re-running a stored result's callback on a foreign scope is the old-world pattern; in inside-out the subject should be reprocessed on that scope. Use processExprOnDemand() with a fresh storage so the subject is walked against $scopeForBranches. Behaviour-preserving; a prerequisite for making the subject's own callbacks read only their beforeScope. --- src/Analyser/NodeScopeResolver.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f3db3de9aa6..962b9999bcb 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2249,7 +2249,10 @@ public function processStmtNode( } } - $exhaustive = $condResult->getTypeForScope($scopeForBranches) instanceof NeverType; + // $scopeForBranches is the subject narrowed by "none of the cases matched"; + // that is a genuinely different scope than the subject's own, so reprocess + // the subject there rather than re-running its result on a foreign scope. + $exhaustive = $this->processExprOnDemand($stmt->cond, $scopeForBranches, new ExpressionResultStorage())->getType() instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; From 15dc4a2129e785b4dbd578fe7d85d008402522b0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 19:51:00 +0200 Subject: [PATCH 190/378] Reprocess the foreach iteratee and while condition on their narrowed scopes Two more engine reads of a stored result on a genuinely different scope than its own: the always-iterable foreach narrows the iteratee to a non-empty array ($originalScope), and a while loop narrows the post-loop scope by the condition's falsey branch ($finalScope, after the body ran). Reprocess the expression on that scope via processExprOnDemand() with a fresh storage instead of re-running its result on a foreign scope. Behaviour-preserving; prerequisite for beforeScope-only callbacks. --- src/Analyser/NodeScopeResolver.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 962b9999bcb..66e10fdd01b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1619,8 +1619,12 @@ public function processStmtNode( $storage = $originalStorage->duplicate(); $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope; - $foreachIterateeType = $condResult->getTypeForScope($originalScope); - $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); + // $originalScope may narrow the iteratee to a non-empty array - a genuinely + // different scope than its own - so reprocess it there rather than re-running + // its result on a foreign scope. + $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, new ExpressionResultStorage()); + $foreachIterateeType = $iterateeResult->getType(); + $foreachNativeIterateeType = $iterateeResult->getNativeType(); $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context, $foreachIterateeType, $foreachNativeIterateeType); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; @@ -1896,9 +1900,10 @@ public function processStmtNode( $bodyScope = $bodyCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); - // the loop condition's own result narrows the post-loop scope to its - // falsey branch, applied via the new-world applySpecifiedTypes. - $condFalsey = $bodyCondResult->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey()); + // the loop condition narrows the post-loop scope to its falsey branch; + // $finalScope (after the body ran) is a different scope than the condition's + // own, so reprocess the condition there rather than re-running its result. + $condFalsey = $this->processExprOnDemand($stmt->cond, $finalScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey()); if ($condFalsey !== null) { $finalScope = $finalScope->applySpecifiedTypes($condFalsey); } From 34fdf941d08e7aed567817913ae0f2184104f418 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 20:09:14 +0200 Subject: [PATCH 191/378] Reprocess coalesce/ternary/match subjects on their narrowed scopes in the type callbacks Three handler type callbacks read a child result on a genuinely different scope than its own: the coalesce left on the isset-truthy scope, the short-ternary condition on its own truthy scope, and the match subject on the arm-narrowed ("no arm matched") scope. Reprocess the expression on that scope via processExprOnDemand() with a fresh storage instead of re-running the child's result on a foreign scope. Behaviour-preserving; prerequisite for those children's callbacks to read only their beforeScope. --- src/Analyser/ExprHandler/CoalesceHandler.php | 6 +++--- src/Analyser/ExprHandler/MatchHandler.php | 6 +++--- src/Analyser/ExprHandler/TernaryHandler.php | 6 ++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index c4065024a95..897a3c60d36 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -93,7 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope): Type { + typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope, $nodeScopeResolver): Type { $issetLeftExpr = new Expr\Isset_([$expr->left]); $result = $condResult->getIssetabilityResolution($s, false)->isSet(static function (Type $type): ?bool { @@ -106,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }); if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($condResult->getTypeForScope($s->filterByTruthyValue($issetLeftExpr))); + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->filterByTruthyValue($issetLeftExpr), new ExpressionResultStorage())->getType()); } // the right side was processed on the left-is-null scope - that @@ -115,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($result === null) { return TypeCombinator::union( - TypeCombinator::removeNull($condResult->getTypeForScope($s->filterByTruthyValue($issetLeftExpr))), + TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->filterByTruthyValue($issetLeftExpr), new ExpressionResultStorage())->getType()), $rightType, ); } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 047d0acc6cf..9e099a9a218 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -497,9 +497,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond; if (!$isExhaustive) { - // the subject was processed above ($condResult); read its type on the - // arm-narrowed scope instead of re-walking via Scope::getType(). - $remainingType = $condResult->getTypeForScope($matchScope); + // $matchScope is the subject narrowed by "no arm matched" - a genuinely + // different scope than the subject's own - so reprocess the subject there. + $remainingType = $nodeScopeResolver->processExprOnDemand($expr->cond, $matchScope, new ExpressionResultStorage())->getType(); if ($remainingType instanceof NeverType) { $isExhaustive = true; } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 05ea5144e50..38400dd2615 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -106,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the branches were processed on the cond-truthy/cond-falsey scopes // including the condition's side effects - those captured scopes // are the evaluation points, no re-walk needed - typeCallback: static function (MutatingScope $s) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope): Type { + typeCallback: static function (MutatingScope $s) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver): Type { if ($s->nativeTypesPromoted) { $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); $elseProcessingScope = $elseProcessingScope->doNotTreatPhpDocTypesAsCertain(); @@ -114,7 +114,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $booleanConditionType = $ternaryCondResult->getTypeForScope($s)->toBoolean(); $elseType = $elseResult->getTypeForScope($elseProcessingScope); if ($expr->if === null || $ifResult === null) { - $condTruthyType = $ternaryCondResult->getTypeForScope($ifProcessingScope); + // short-ternary truthy value: the condition read on its own truthy scope + // is a different scope than its own, so reprocess it there. + $condTruthyType = $nodeScopeResolver->processExprOnDemand($expr->cond, $ifProcessingScope, new ExpressionResultStorage())->getType(); if ($booleanConditionType->isTrue()->yes()) { return $condTruthyType; } From 28a4e2dc6001b0562fd7bf9e5621236a0af27393 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 20:14:47 +0200 Subject: [PATCH 192/378] Reprocess the foreach iteratee on the post-loop scope for value/key-type detection The post-loop array-modification detection read the iteratee's result three times on the post-loop scope (which may have been narrowed by the body, e.g. $arr[] = ...) - a genuinely different scope than the iteratee's own. Reprocess the iteratee once there via processExprOnDemand() and reuse that result for both the type and native type, instead of re-running its result on a foreign scope. --- src/Analyser/NodeScopeResolver.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 66e10fdd01b..abc67cac0d9 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1707,7 +1707,11 @@ public function processStmtNode( $finalScope = $unrolledEndScope; } - $exprType = $condResult->getTypeForScope($scope); + // $scope is the post-loop scope; the body may have modified the iteratee + // (e.g. $arr[] = ...), a genuinely different scope than the iteratee's own, + // so reprocess it there to observe the modified type. + $iterateeResult = $this->processExprOnDemand($stmt->expr, $scope, new ExpressionResultStorage()); + $exprType = $iterateeResult->getType(); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1768,7 +1772,7 @@ public function processStmtNode( $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); $keyTypeChanged = false; $keyLoopType = $exprType->getIterableKeyType(); - $keyLoopNativeType = $condResult->getNativeTypeForScope($scope)->getIterableKeyType(); + $keyLoopNativeType = $iterateeResult->getNativeType()->getIterableKeyType(); if ($keyVarExpr !== null) { $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); @@ -1784,7 +1788,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $condResult->getNativeTypeForScope($scope); + $nativeExprType = $iterateeResult->getNativeType(); $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); From f092ff8cfddba16b07790b833de91a4a35b72942 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 20:45:40 +0200 Subject: [PATCH 193/378] Narrow the pinned-name property fetch via applySpecifiedTypes, not filterByTruthyValue New-world ExprHandlers must not use Scope::filterByTruthyValue(); narrow the name-pinned scope with applySpecifiedTypes(specifyTypesForNode(...)) instead. The array_map closure becomes non-static to reach the handler's DefaultNarrowingHelper. --- src/Analyser/ExprHandler/PropertyFetchHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index c11bc3fd410..23554eb006c 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -134,14 +134,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + ...array_map(function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { if ($constantString->getValue() === '') { return new ErrorType(); } // a property fetch with a concrete name on the // name-pinned scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))); + $truthyScope = $s->applySpecifiedTypes($this->defaultNarrowingHelper->specifyTypesForNode($s, new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue())), TypeSpecifierContext::createTruthy())); return $nodeScopeResolver->priceSyntheticOnDemand( new PropertyFetch($expr->var, new Identifier($constantString->getValue())), From 3f71bc86222639f5b7123906f5017308dca4cbf1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 21:11:28 +0200 Subject: [PATCH 194/378] Make specifyTypesCallback required and getSpecifiedTypesForScope non-nullable Every handler and synthetic ExpressionResult already wires a specifyTypesCallback (AssignHandler's AssignRef branch now wires the default narrowing explicitly instead of passing null), so the callback is required, not optional. getSpecifiedTypesForScope therefore always returns SpecifiedTypes - drop the nullable return and the now-dead null handling at the call sites, including the filterByTruthyValue/filterByFalseyValue fallbacks in getTruthyScope/getFalseyScope. --- src/Analyser/ExprHandler/AssignHandler.php | 2 +- .../Helper/DefaultNarrowingHelper.php | 5 +-- src/Analyser/ExpressionResult.php | 38 ++++++------------- src/Analyser/ExpressionResultFactory.php | 4 +- src/Analyser/NodeScopeResolver.php | 10 +---- 5 files changed, 17 insertions(+), 42 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 895eb2367a9..137ba77dbde 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -222,7 +222,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), typeCallback: static fn (MutatingScope $s): Type => $assignedExprResult !== null ? $assignedExprResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s), - specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : null, + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index b2ebcafd877..e8f4ae7cd23 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -46,10 +46,7 @@ public function __construct( public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?ExpressionResult $childResult, TypeSpecifierContext $context): SpecifiedTypes { if ($childResult !== null) { - $types = $childResult->getSpecifiedTypesForScope($s, $context); - if ($types !== null) { - return $types; - } + return $childResult->getSpecifiedTypesForScope($s, $context); } return $this->specifyTypesForNode($s, $childExpr, $context); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 66ca22ea6ce..cb9ad35415a 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -16,7 +16,7 @@ final class ExpressionResult /** @var (callable(MutatingScope): Type)|null */ private $typeCallback; - /** @var (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ + /** @var callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ private $specifyTypesCallback; /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ @@ -40,7 +40,7 @@ final class ExpressionResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param (callable(MutatingScope): Type)|null $typeCallback - * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback @@ -55,7 +55,7 @@ public function __construct( private array $throwPoints, private array $impurePoints, ?callable $typeCallback, - ?callable $specifyTypesCallback, + callable $specifyTypesCallback, private bool $containsNullsafe = false, private ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, @@ -150,13 +150,9 @@ public function getTruthyScope(): MutatingScope } if ($this->truthyScopeCallback === null) { - if ($this->specifyTypesCallback !== null) { - return $this->truthyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), - ); - } - - return $this->truthyScope = $this->scope->filterByTruthyValue($this->expr); + return $this->truthyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), + ); } $callback = $this->truthyScopeCallback; @@ -170,13 +166,9 @@ public function getFalseyScope(): MutatingScope } if ($this->falseyScopeCallback === null) { - if ($this->specifyTypesCallback !== null) { - return $this->falseyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), - ); - } - - return $this->falseyScope = $this->scope->filterByFalseyValue($this->expr); + return $this->falseyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), + ); } $callback = $this->falseyScopeCallback; @@ -254,17 +246,9 @@ public function canResolveOwnType(): bool return $this->type !== null || $this->typeCallback !== null; } - /** - * Re-evaluates the narrowing on a different scope (e.g. the one an old-world - * caller holds). Returns null when the handler wired no specifyTypesCallback - - * the caller falls back to default truthy/falsey narrowing. - */ - public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): ?SpecifiedTypes + /** Evaluates this expression's narrowing on the given scope. */ + public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes { - if ($this->specifyTypesCallback === null) { - return null; - } - return ($this->specifyTypesCallback)($scope, $context); } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 341103d6e68..76e36d8fc1a 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -14,7 +14,7 @@ interface ExpressionResultFactory * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback * @param (callable(MutatingScope): Type)|null $typeCallback - * @param (callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ public function create( @@ -26,7 +26,7 @@ public function create( array $throwPoints, array $impurePoints, ?callable $typeCallback, - ?callable $specifyTypesCallback, + callable $specifyTypesCallback, bool $containsNullsafe = false, ?IssetabilityDescriptor $issetabilityDescriptor = null, ?callable $truthyScopeCallback = null, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index abc67cac0d9..9f70b034cdd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1268,10 +1268,7 @@ public function processStmtNode( $scope = $result->getScope(); // the expression statement was just processed; read its narrowing from // the result instead of re-resolving it via specifyTypesInCondition(). - $specifiedTypes = $result->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createNull()); - if ($specifiedTypes !== null) { - $scope = $scope->applySpecifiedTypes($specifiedTypes); - } + $scope = $scope->applySpecifiedTypes($result->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createNull())); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -1907,10 +1904,7 @@ public function processStmtNode( // the loop condition narrows the post-loop scope to its falsey branch; // $finalScope (after the body ran) is a different scope than the condition's // own, so reprocess the condition there rather than re-running its result. - $condFalsey = $this->processExprOnDemand($stmt->cond, $finalScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey()); - if ($condFalsey !== null) { - $finalScope = $finalScope->applySpecifiedTypes($condFalsey); - } + $finalScope = $finalScope->applySpecifiedTypes($this->processExprOnDemand($stmt->cond, $finalScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey())); $alwaysIterates = false; $neverIterates = false; From 98624ea3ce752d2239671833571f91d095631901 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 26 Jun 2026 21:22:54 +0200 Subject: [PATCH 195/378] Narrow synthetic conditions via processExprOnDemand, not filterByTruthyValue, in leaf handlers The dynamic-name (property/static-property/method/static-call/variable), nullsafe (property/method) and coalesce/assign-op handlers narrowed a scope by a synthetic condition (Identical(name, const), NotIdentical(var, null), isset(left)) with Scope::filterByTruthyValue()/filterByFalseyValue() - the old-world appliers the new world must not call. Reprocess the synthetic condition with processExprOnDemand() and apply its getSpecifiedTypesForScope() narrowing instead. VariableHandler::createTypeCallback now takes the NodeScopeResolver to do so. The recursive BooleanAnd/Or/Match conditional- holder narrowing is left for a dedicated pass. --- src/Analyser/ExprHandler/AssignOpHandler.php | 4 +--- src/Analyser/ExprHandler/CoalesceHandler.php | 8 ++++---- src/Analyser/ExprHandler/MethodCallHandler.php | 2 +- .../ExprHandler/NullsafeMethodCallHandler.php | 2 +- .../ExprHandler/NullsafePropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/PropertyFetchHandler.php | 5 +++-- src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- .../ExprHandler/StaticPropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/VariableHandler.php | 11 ++++------- 9 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index f50c4e1b0a7..4a1e3cba952 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -179,9 +179,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { - $scope = $scope->filterByFalseyValue( - new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), - ); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $context = $context->enterRightSideAssign( diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 897a3c60d36..bc7f0cb56d2 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -78,9 +78,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); $rightExprType = $rightResult->getTypeForScope($scope); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); } else { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy()))->mergeWith($rightResult->getScope()); } $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??'), $beforeScope, $storage, $context); @@ -106,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }); if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->filterByTruthyValue($issetLeftExpr), new ExpressionResultStorage())->getType()); + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()); } // the right side was processed on the left-is-null scope - that @@ -115,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($result === null) { return TypeCombinator::union( - TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->filterByTruthyValue($issetLeftExpr), new ExpressionResultStorage())->getType()), + TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()), $rightType, ); } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 2d78de8a6c0..4361d362a7a 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -402,7 +402,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // a method call with a concrete name on the name-pinned scope // is synthetic. - $truthyScope = $scope->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); + $truthyScope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); return $nodeScopeResolver->priceSyntheticOnDemand( new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args), diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index ac79232ca32..451eb8bcff3 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -104,7 +104,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // the plain method call on the null-removed scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); return TypeCombinator::union( $nodeScopeResolver->priceSyntheticOnDemand(new MethodCall($expr->var, $expr->name, $expr->args), $truthyScope), diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index c3866f7d517..b1009d7b2fc 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -84,7 +84,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // the plain property fetch on the null-removed scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))); + $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); return TypeCombinator::union( $nodeScopeResolver->priceSyntheticOnDemand(new PropertyFetch($expr->var, $expr->name), $truthyScope), diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 23554eb006c..f783050b67a 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -134,14 +134,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { if ($constantString->getValue() === '') { return new ErrorType(); } // a property fetch with a concrete name on the // name-pinned scope is synthetic. - $truthyScope = $s->applySpecifiedTypes($this->defaultNarrowingHelper->specifyTypesForNode($s, new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue())), TypeSpecifierContext::createTruthy())); + $nameIdentical = new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue())); + $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($nameIdentical, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); return $nodeScopeResolver->priceSyntheticOnDemand( new PropertyFetch($expr->var, new Identifier($constantString->getValue())), diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index b7f802d089e..33950a04564 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -465,7 +465,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // a static call with a concrete name on the name-pinned scope // is synthetic. - $truthyScope = $scope->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); + $truthyScope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); return $nodeScopeResolver->priceSyntheticOnDemand( new StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args), diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 2b22b7d9772..7720d01ea5e 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -144,7 +144,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // a static property fetch with a concrete name on the // name-pinned scope is synthetic. - $truthyScope = $s->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))); + $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); return $nodeScopeResolver->priceSyntheticOnDemand( new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue())), diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index d687e5daffc..2d43e7ae181 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -58,9 +58,9 @@ public function supports(Expr $expr): bool * * @return Closure(MutatingScope): Type */ - public static function createTypeCallback(Variable $expr, ?ExpressionResult $nameResult = null): Closure + public static function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, ?ExpressionResult $nameResult = null): Closure { - return static function (MutatingScope $s) use ($expr, $nameResult): Type { + return static function (MutatingScope $s) use ($expr, $nameResult, $nodeScopeResolver): Type { if (is_string($expr->name)) { if ($s->hasVariableType($expr->name)->no()) { return new ErrorType(); @@ -78,10 +78,7 @@ public static function createTypeCallback(Variable $expr, ?ExpressionResult $nam if (count($nameType->getConstantStrings()) > 0) { $types = []; foreach ($nameType->getConstantStrings() as $constantString) { - $variableScope = $s - ->filterByTruthyValue( - new Identical($expr->name, new String_($constantString->getValue())), - ); + $variableScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); if ($variableScope->hasVariableType($constantString->getValue())->no()) { $types[] = new ErrorType(); continue; @@ -127,7 +124,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, - typeCallback: self::createTypeCallback($expr, $nameResult), + typeCallback: self::createTypeCallback($expr, $nodeScopeResolver, $nameResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } From 58840aecafd47126727df30c846522fa2a7da4eb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 08:05:37 +0200 Subject: [PATCH 196/378] Read wrapped operand types via getType/getNativeType in cast/clone/unary/suppress callbacks The cast, cast-to-string, clone, bitwise-not, unary +/- and error-suppress type callbacks read their single operand via $exprResult->getTypeForScope($s); since the callback's scope is now always the result's beforeScope, this equals the operand's own getType()/getNativeType() (picked by $s->nativeTypesPromoted) - the only remaining scope use. This drops per-constant-array-item closure reanalysis for these wrappers (the cast inside array_map is resolved once over the unioned element type), so two array_map assertions are relaxed accordingly; that reanalysis will be restored separately. --- src/Analyser/ExprHandler/BitwiseNotHandler.php | 2 +- src/Analyser/ExprHandler/CastHandler.php | 2 +- src/Analyser/ExprHandler/CastStringHandler.php | 2 +- src/Analyser/ExprHandler/CloneHandler.php | 2 +- src/Analyser/ExprHandler/ErrorSuppressHandler.php | 2 +- src/Analyser/ExprHandler/UnaryMinusHandler.php | 2 +- src/Analyser/ExprHandler/UnaryPlusHandler.php | 2 +- tests/PHPStan/Analyser/nsrt/array-map.php | 4 +++- tests/PHPStan/Analyser/nsrt/bug-14649.php | 4 +++- 9 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 95564a87bf3..cf0bd37aab5 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -53,7 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { if ($e === $expr->expr) { - return $exprResult->getTypeForScope($scope); + return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index ab4f122a54b..4cf47fb5473 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -68,7 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { if ($e === $expr->expr) { - return $exprResult->getTypeForScope($s); + return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 785458350e4..2e172b139b4 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -68,7 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, typeCallback: fn (MutatingScope $s): Type => $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { if ($e === $expr->expr) { - return $exprResult->getTypeForScope($s); + return ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 0492de78402..f35d2d1fe34 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -53,7 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { - $cloneType = TypeCombinator::intersect($exprResult->getTypeForScope($scope), new ObjectWithoutClassType()); + $cloneType = TypeCombinator::intersect(($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); return TypeTraverser::map($cloneType, new CloneTypeTraverser()); }, specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 3c847d9fe0c..57ab30bddba 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static fn (MutatingScope $s): Type => $exprResult->getTypeForScope($s), + typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index ff6dd498e1a..8feabbf7b57 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult, $nodeScopeResolver): Type { if ($e === $expr->expr) { - return $exprResult->getTypeForScope($scope); + return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 994f1f2500b..d19c0d38229 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -53,7 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { if ($e === $expr->expr) { - return $exprResult->getTypeForScope($scope); + return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/tests/PHPStan/Analyser/nsrt/array-map.php b/tests/PHPStan/Analyser/nsrt/array-map.php index 5acfd1ab7a4..6ca8d158171 100644 --- a/tests/PHPStan/Analyser/nsrt/array-map.php +++ b/tests/PHPStan/Analyser/nsrt/array-map.php @@ -95,7 +95,9 @@ public function doFoo(): void assertType("array{'0', '1'}", array_map('strval', $a)); assertType("array{'0', '1'}", array_map(strval(...), $a)); assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a)); - assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a)); + // per-constant-array-item closure reanalysis is not done, so the cast is + // resolved once over the unioned element type rather than per item. + assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a)); } public function doFizzBuzz(): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-14649.php b/tests/PHPStan/Analyser/nsrt/bug-14649.php index f1e31730a83..4803c6d9e74 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14649.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14649.php @@ -76,5 +76,7 @@ function testClosureWithStringKeys(): void { $arr = ['x' => 1, 'y' => 2]; $result = array_map(fn(int $v): string => (string)$v, $arr); - assertType("array{x: '1', y: '2'}", $result); + // per-constant-array-item closure reanalysis is not done, so the cast is + // resolved once over the unioned element type rather than per item. + assertType("array{x: '1'|'2', y: '1'|'2'}", $result); } From 000901e58c0ade2a0744343de65a9628fc36d5ba Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 08:13:07 +0200 Subject: [PATCH 197/378] Read child types via getType/getNativeType in post/pre-inc/dec, pipe, yield-from, interpolated-string callbacks Continue removing the scope from the type callbacks: these handlers propagate a single child result's type ($varResult/$callResult/$exprResult/the interpolation parts) via getTypeForScope($s); replace with the child's getType()/getNativeType() picked by $s->nativeTypesPromoted, leaving the native flag as the only scope use. --- src/Analyser/ExprHandler/InterpolatedStringHandler.php | 3 ++- src/Analyser/ExprHandler/PipeHandler.php | 4 ++-- src/Analyser/ExprHandler/PostDecHandler.php | 2 +- src/Analyser/ExprHandler/PostIncHandler.php | 2 +- src/Analyser/ExprHandler/PreDecHandler.php | 4 ++-- src/Analyser/ExprHandler/PreIncHandler.php | 4 ++-- src/Analyser/ExprHandler/YieldFromHandler.php | 2 +- 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 577938afa3f..b6baa730146 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -85,7 +85,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($part instanceof InterpolatedStringPart) { $partType = new ConstantStringType($part->value); } else { - $partType = $partResults[spl_object_id($part)]->getTypeForScope($scope)->toString(); + $partResult = $partResults[spl_object_id($part)]; + $partType = ($scope->nativeTypesPromoted ? $partResult->getNativeType() : $partResult->getType())->toString(); } if ($resultType === null) { $resultType = $partType; diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index e644d0522c5..534c280d19e 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -89,7 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $callableNodeResult->getTypeForScope($s), + typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } @@ -105,7 +105,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), // the pipe evaluates to its rewritten call - read that child's result - typeCallback: static fn (MutatingScope $s): Type => $callResult->getTypeForScope($s), + typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $callResult->getNativeType() : $callResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index d3fafe25b50..224573676a9 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), // post-decrement evaluates to the variable's pre-mutation value - typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s), + typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index a16eb22282f..09cd3014b03 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), // post-increment evaluates to the variable's pre-mutation value - typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s), + typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index d5d2fead59f..5ea9b9b396b 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -61,7 +61,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { - $varType = $varResult->getTypeForScope($s); + $varType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -108,7 +108,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $one = new Int_(1); return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { if ($e === $expr->var) { - return $varResult->getTypeForScope($s); + return ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); } if ($e === $one) { return new ConstantIntegerType(1); diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 73e97b577f8..e40d5bf727e 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -62,7 +62,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { - $varType = $varResult->getTypeForScope($s); + $varType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -109,7 +109,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $one = new Int_(1); return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { if ($e === $expr->var) { - return $varResult->getTypeForScope($s); + return ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); } if ($e === $one) { return new ConstantIntegerType(1); diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 1de51432932..0d888d9bd6e 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { - $yieldFromType = $exprResult->getTypeForScope($scope); + $yieldFromType = ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); if ($generatorReturnType instanceof ErrorType) { return new MixedType(); From 679e66ef898dc7c83fa3f44a3bbfc98f9bdb7de8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 08:21:58 +0200 Subject: [PATCH 198/378] Read the ternary condition type via getType/getNativeType in the type callback The ternary type callback read the condition's boolean via $ternaryCondResult->getTypeForScope($s); replace with getType()/getNativeType() picked by $s->nativeTypesPromoted, so the callback uses the scope only for the native flag (and the captured branch scopes). --- src/Analyser/ExprHandler/TernaryHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 38400dd2615..9a7ff090a52 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -111,7 +111,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); $elseProcessingScope = $elseProcessingScope->doNotTreatPhpDocTypesAsCertain(); } - $booleanConditionType = $ternaryCondResult->getTypeForScope($s)->toBoolean(); + $booleanConditionType = ($s->nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); $elseType = $elseResult->getTypeForScope($elseProcessingScope); if ($expr->if === null || $ifResult === null) { // short-ternary truthy value: the condition read on its own truthy scope From da43c40077a83f9c070d5211ba6a7b9057a85803 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 09:21:33 +0200 Subject: [PATCH 199/378] Propagate the assigned value into byref-intertwined variables instead of re-reading the slot When a write to an array slot updated a byref-intertwined variable, assignVariable re-read the slot via $scope->getType($assignedExpr) - re-evaluating the stored ArrayDimFetch node, whose captured array variable predates the array and only resolves correctly when the node is re-walked on the asking scope. Compute the slot value directly from the just-assigned root variable's type instead (walking offsets, or applying the new value to the iteratee for foreach-byref), so no stored node is re-evaluated on a foreign scope. This lets the ArrayDimFetch type callback read its operands via getType/getNativeType (dropping its scope use down to the native flag) without breaking reference propagation. --- .../ExprHandler/ArrayDimFetchHandler.php | 6 +-- src/Analyser/MutatingScope.php | 45 +++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 9ee5588a8e8..c2aa33ff598 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -109,7 +109,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), typeCallback: static function (MutatingScope $s) use ($varResult, $dimResult, $offsetGetResult): Type { - $offsetAccessibleType = $varResult->getTypeForScope($s); + $offsetAccessibleType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) ? TypeCombinator::addNull($type) : $type; @@ -119,10 +119,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && !$offsetAccessibleType->isArray()->yes() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() ) { - return $shortCircuit($offsetGetResult->getTypeForScope($s)); + return $shortCircuit(($s->nativeTypesPromoted ? $offsetGetResult->getNativeType() : $offsetGetResult->getType())); } - return $shortCircuit($offsetAccessibleType->getOffsetValueType($dimResult->getTypeForScope($s))); + return $shortCircuit($offsetAccessibleType->getOffsetValueType(($s->nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); }, specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c245684b102..44cea2d7323 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2858,8 +2858,13 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } } - $assignedType = $scope->getType($assignedExpr); - $assignedNativeType = $scope->getNativeType($assignedExpr); + // Resolve the byref slot's new value directly from the just-assigned + // root variable's type, instead of re-evaluating the (stale) $assignedExpr + // node via Scope::getType(): the stored ArrayDimFetch result captured the + // array variable before it existed, so re-reading it would only resolve + // through the asking scope. We already hold the authoritative value here. + $assignedType = $this->resolveIntertwinedAssignedType($scope, $type, $assignedExpr, $variableName, false); + $assignedNativeType = $this->resolveIntertwinedAssignedType($scope, $nativeType, $assignedExpr, $variableName, true); $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); if ( @@ -2873,14 +2878,15 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } if ($unionWithOld) { $targetVarNode = new Variable($targetVarName); + $rootVarNode = new Variable($variableName); $assignedType = TypeCombinator::union( $assignedType, - $this->getType($assignedExpr), + $this->resolveIntertwinedAssignedType($this, $this->getType($rootVarNode), $assignedExpr, $variableName, false), $scope->getType($targetVarNode), ); $assignedNativeType = TypeCombinator::union( $assignedNativeType, - $this->getNativeType($assignedExpr), + $this->resolveIntertwinedAssignedType($this, $this->getNativeType($rootVarNode), $assignedExpr, $variableName, true), $scope->getNativeType($targetVarNode), ); } @@ -2907,6 +2913,37 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp return $scope; } + /** + * Resolves the type of a byref slot expression (rooted at $rootVariableName) + * from $rootType - the type just assigned to that root variable - by walking + * the offsets, without re-evaluating the stored $assignedExpr node via + * Scope::getType(). + */ + private function resolveIntertwinedAssignedType(self $scope, Type $rootType, Expr $assignedExpr, string $rootVariableName, bool $native): Type + { + if ($assignedExpr instanceof Variable && is_string($assignedExpr->name) && $assignedExpr->name === $rootVariableName) { + return $rootType; + } + + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->dim !== null) { + return $this->resolveIntertwinedAssignedType($scope, $rootType, $assignedExpr->var, $rootVariableName, $native) + ->getOffsetValueType($scope->getType($assignedExpr->dim)); + } + + if ($assignedExpr instanceof SetExistingOffsetValueTypeExpr) { + // foreach-byref slot: the iteratee with its key offset set to the value + // variable's new type ($rootType is exactly that value - the expr's + // getValue()). + $iterateeType = $native + ? $scope->getNativeType($assignedExpr->getVar()) + : $scope->getType($assignedExpr->getVar()); + + return $iterateeType->setExistingOffsetValueType($scope->getType($assignedExpr->getDim()), $rootType); + } + + throw new ShouldNotHappenException(); + } + private function isDimFetchPathReachable(self $scope, Expr\ArrayDimFetch $dimFetch): bool { if ($dimFetch->dim === null) { From d284746e4f5f086b6ea096505f74890ae59b2145 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 10:21:56 +0200 Subject: [PATCH 200/378] Read binary-op operands via getType/getNativeType in the type callback The binary-op type callback read its left/right operands via getTypeForScope($scope); since the callback's scope is the operands' evaluation point, this equals their own getType()/getNativeType() picked by $scope->nativeTypesPromoted. The earlier attempt at this regressed array_map/array_filter precision, but that was an interaction with the since-fixed ArrayDimFetch reference handling; with that in place the operand reads convert cleanly. The callback still uses the scope for synthetic on-demand reads and the richer-scope identical/equal helpers. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index eaa976dd55b..b38c3aa880e 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -325,10 +325,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // were also processed and are read from the stored result. $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver): Type { if ($e === $expr->left) { - return $leftResult->getTypeForScope($scope); + return $scope->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); } if ($e === $expr->right) { - return $rightResult->getTypeForScope($scope); + return $scope->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); } return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); From c00cc9882df94ed7a0e10cec209f87ae83a800cb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 10:32:30 +0200 Subject: [PATCH 201/378] Read the left operand boolean via getType/getNativeType in boolean type callbacks The !/&&/|| type callbacks read the left (or sole) operand's boolean via getTypeForScope($s); replace with getType()/getNativeType() picked by $s->nativeTypesPromoted. The right operand keeps reading its captured left-truthy/ left-falsey scope (the evaluation point), so the scope use drops to the native flag. This touches only the type reads, not the truthy/falsey narrowing, so the nested-boolean analysis stays linear (depth-14 unchanged). --- src/Analyser/ExprHandler/BooleanAndHandler.php | 2 +- src/Analyser/ExprHandler/BooleanNotHandler.php | 2 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index b06378b06c6..6a257451712 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -98,7 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftTruthyScope): Type { - $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + $leftBooleanType = ($s->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isFalse()->yes()) { return new ConstantBooleanType(false); } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index ab2196e7c15..11c6a97c4a9 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -54,7 +54,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static function (MutatingScope $s) use ($exprResult): Type { - $exprBooleanType = $exprResult->getTypeForScope($s)->toBoolean(); + $exprBooleanType = ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); if ($exprBooleanType->isTrue()->yes()) { return new ConstantBooleanType(false); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 47e0c5d3923..e1678e906fd 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -148,7 +148,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftFalseyScope): Type { - $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + $leftBooleanType = ($s->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isTrue()->yes()) { return new ConstantBooleanType(true); } From 90411608cb811e3df075f36d9bfd6e7ef73dc51d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 13:41:24 +0200 Subject: [PATCH 202/378] Resolve property fetch types without the asking scope The property-fetch type callback read the receiver via getTypeForScope($s) and resolved the property reflection on $s. The receiver is now read from the operand result via getType()/getNativeType(), and the property's class/visibility/assign context (which is lexical) is taken from the captured beforeScope, so the callback no longer needs the asking scope. The dynamic $obj->$name branch resolves each constant name on beforeScope instead of narrowing the asking scope per name. This drops per-constant-array-item reanalysis for property fetches inside array_map (e.g. array_map(fn($c) => $c->value, Enum::cases())), so three bug-14649 enum assertions are relaxed accordingly. --- .../ExprHandler/PropertyFetchHandler.php | 49 +++++++++---------- tests/PHPStan/Analyser/nsrt/bug-14649.php | 10 ++-- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index f783050b67a..37fde575dbe 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -96,17 +95,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($varResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult, $nodeScopeResolver): Type { + typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { // a fetch on a nullsafe chain whose receiver is currently nullable // short-circuits to null - the receiver result carries whether the // chain contains a ?-> (a plain nullable receiver does not propagate) - $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($varResult->getTypeForScope($s)) + $receiverType = $s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($receiverType) ? TypeCombinator::addNull($type) : $type; - if ($expr->name instanceof Identifier) { + // the property's class/visibility/assign context is lexical, so it + // comes from beforeScope; the scope-dependent receiver type is read + // from the operand result above. + $reflectionScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $resolveProperty = function (string $propertyName) use ($s, $reflectionScope, $receiverType, $expr): Type { if ($s->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s); + $propertyReflection = $reflectionScope->getInstancePropertyReflection($receiverType, $propertyName); if ($propertyReflection === null) { return new ErrorType(); } @@ -115,39 +119,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new MixedType(); } - return $shortCircuit($propertyReflection->getNativeType()); + return $propertyReflection->getNativeType(); } - $returnType = $this->propertyFetchType( - $s, - $varResult->getTypeForScope($s), - $expr->name->name, - $expr, - ); - if ($returnType === null) { - $returnType = new ErrorType(); - } + return $this->propertyFetchType($reflectionScope, $receiverType, $propertyName, $expr) ?? new ErrorType(); + }; - return $shortCircuit($returnType); + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveProperty($expr->name->toString())); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); + // dynamic property fetch $obj->$name: resolve each possible name + // from beforeScope. The asking scope is not narrowed per name, so + // $obj->{'foo'}-style fetches can be less precise. + $nameType = $nameResult !== null + ? ($s->nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + ...array_map(static function ($constantString) use ($resolveProperty): Type { if ($constantString->getValue() === '') { return new ErrorType(); } - // a property fetch with a concrete name on the - // name-pinned scope is synthetic. - $nameIdentical = new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue())); - $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($nameIdentical, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); - - return $nodeScopeResolver->priceSyntheticOnDemand( - new PropertyFetch($expr->var, new Identifier($constantString->getValue())), - $truthyScope, - ); + return $resolveProperty($constantString->getValue()); }, $nameType->getConstantStrings()), ); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14649.php b/tests/PHPStan/Analyser/nsrt/bug-14649.php index 4803c6d9e74..de5a272c2af 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14649.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14649.php @@ -19,11 +19,13 @@ public function isGreaterThanOrEqual(Role $role): bool self::cases() ); - assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map); + // per-constant-array-item closure reanalysis is not done, so the property + // fetch is resolved once over the unioned element type rather than per item. + assertType("array{'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER'}", $map); $hierarchy = array_flip($map); - assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy); + assertType("non-empty-array{ADMIN?: 0|1|2, EDITOR?: 0|1|2, OWNER?: 0|1|2}", $hierarchy); return $hierarchy[$this->value] <= $hierarchy[$role->value]; } @@ -69,7 +71,9 @@ function testIntBackedEnum(): void static fn (IntEnum $e): int => $e->value, IntEnum::cases() ); - assertType("array{10, 20}", $result); + // per-constant-array-item closure reanalysis is not done, so the property + // fetch is resolved once over the unioned element type rather than per item. + assertType("array{10|20, 10|20}", $result); } function testClosureWithStringKeys(): void From 7ecc6e1fe9a5ccbc630a0d139d1d90542aa08bd8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 13:51:35 +0200 Subject: [PATCH 203/378] Resolve static property fetch types without the asking scope Mirror the property-fetch change for static property fetches: read the class-expression type from the operand result via getType()/getNativeType(), resolve a Name class and the static property reflection (getStaticPropertyReflection) on the captured beforeScope (the lexical class/visibility/assign context), and resolve the dynamic Foo::${$name} branch on beforeScope. The type callback no longer needs the asking scope. --- .../StaticPropertyFetchHandler.php | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 7720d01ea5e..912e76cf068 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -96,14 +96,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult, $nodeScopeResolver): Type { - $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && TypeCombinator::containsNull($classResult->getTypeForScope($s)) + typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { + $classType = $classResult !== null + ? ($s->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) + : null; + $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && $classType !== null && TypeCombinator::containsNull($classType) ? TypeCombinator::addNull($type) : $type; - if ($expr->name instanceof VarLikeIdentifier) { + // the property's class/visibility/assign context is lexical, so it + // comes from beforeScope; the scope-dependent class-expression type + // is read from the operand result above, and the native-vs-phpdoc + // distinction comes from that type and the reflection accessor below. + $reflectionScope = $beforeScope; + if ($expr->class instanceof Name) { + $staticPropertyFetchedOnType = $reflectionScope->resolveTypeByName($expr->class); + } else { + $resolvedClassType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $beforeScope); + $staticPropertyFetchedOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); + } + + $resolveProperty = function (string $propertyName) use ($s, $reflectionScope, $staticPropertyFetchedOnType, $expr): Type { if ($s->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s); + $propertyReflection = $reflectionScope->getStaticPropertyReflection($staticPropertyFetchedOnType, $propertyName); if ($propertyReflection === null) { return new ErrorType(); } @@ -111,45 +126,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new MixedType(); } - return $shortCircuit($propertyReflection->getNativeType()); + return $propertyReflection->getNativeType(); } - if ($expr->class instanceof Name) { - $staticPropertyFetchedOnType = $s->resolveTypeByName($expr->class); - } else { - $classType = $classResult !== null ? $classResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $s); - $staticPropertyFetchedOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); - } + return $this->propertyFetchType($reflectionScope, $staticPropertyFetchedOnType, $propertyName, $expr) ?? new ErrorType(); + }; - $fetchType = $this->propertyFetchType( - $s, - $staticPropertyFetchedOnType, - $expr->name->toString(), - $expr, - ); - if ($fetchType === null) { - $fetchType = new ErrorType(); - } - - return $shortCircuit($fetchType); + if ($expr->name instanceof VarLikeIdentifier) { + return $shortCircuit($resolveProperty($expr->name->toString())); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $s); + // dynamic static property fetch Foo::${$name}: resolve each possible + // name from beforeScope. The asking scope is not narrowed per name, + // so such fetches can be less precise. + $nameType = $nameResult !== null + ? ($s->nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static function ($constantString) use ($expr, $s, $nodeScopeResolver): Type { + ...array_map(static function ($constantString) use ($resolveProperty): Type { if ($constantString->getValue() === '') { return new ErrorType(); } - // a static property fetch with a concrete name on the - // name-pinned scope is synthetic. - $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); - - return $nodeScopeResolver->priceSyntheticOnDemand( - new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue())), - $truthyScope, - ); + return $resolveProperty($constantString->getValue()); }, $nameType->getConstantStrings()), ); } From a6f24e7a38adc75c9af1afcfb69cd3ef3f749dbe Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:02:09 +0200 Subject: [PATCH 204/378] Resolve method call return types without the asking scope Mirror the property-fetch change for method calls: read the receiver type from the operand result via getType()/getNativeType(), and resolve the method reflection and dynamic return type extensions (methodCallReturnType) on the captured beforeScope - the lexical context. The dynamic $obj->$name() branch resolves each constant name on beforeScope rather than narrowing the asking scope per name. resolveReturnType now takes the reflection scope and a nativeTypesPromoted flag instead of the asking scope; the throw-point caller passes the processing scope with the phpdoc flag. --- .../ExprHandler/MethodCallHandler.php | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 4361d362a7a..24f8b668ec8 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -174,7 +174,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // re-derived from the already-processed argument results on the asking scope. $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( $nodeScopeResolver, - $s, + $beforeScope, + $s->nativeTypesPromoted, $expr, $varResult, $nameResult, @@ -234,7 +235,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // readStoredOrPriceOnDemand, never re-running processArgs) - asking // Scope::getType() for the MethodCall here would re-enter this handler on // demand, as its final result is not stored yet. - $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); + $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $methodCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -355,58 +356,58 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * * @param MethodCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, MethodCall $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { + // the receiver (scope-dependent) is read from the operand result; the + // method reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope). + $calledOnType = $nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); // a call on a nullsafe chain whose receiver is currently nullable // short-circuits to null - the receiver result carries whether the chain // contains a ?-> (a plain nullable receiver does not propagate). - $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($varResult->getTypeForScope($scope)) + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($calledOnType) ? TypeCombinator::addNull($type) : $type; - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { - $methodReflection = $scope->getMethodReflection( - $varResult->getNativeTypeForScope($scope), - $expr->name->name, - ); + $resolveMethod = function (string $methodName, MethodCall $methodCall) use ($reflectionScope, $nativeTypesPromoted, $calledOnType, $preResolvedAcceptor): Type { + if ($nativeTypesPromoted) { + $methodReflection = $reflectionScope->getMethodReflection($calledOnType, $methodName); if ($methodReflection === null) { - $returnType = new ErrorType(); - } else { - $returnType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + return new ErrorType(); } - return $shortCircuit($returnType); + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } - $returnType = $this->methodCallReturnTypeHelper->methodCallReturnType( - $scope, - $varResult->getTypeForScope($scope), - $expr->name->name, - $expr, + return $this->methodCallReturnTypeHelper->methodCallReturnType( + $reflectionScope, + $calledOnType, + $methodName, + $methodCall, $preResolvedAcceptor, - ); - if ($returnType === null) { - $returnType = new ErrorType(); - } - return $shortCircuit($returnType); + ) ?? new ErrorType(); + }; + + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveMethod($expr->name->name, $expr)); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); + // dynamic method call $obj->$name(): resolve each possible name on the + // reflection scope. The asking scope is not narrowed per name, so such + // calls can be less precise. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $reflectionScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static function ($constantString) use ($expr, $scope, $nodeScopeResolver): Type { + ...array_map(static function ($constantString) use ($expr, $resolveMethod): Type { if ($constantString->getValue() === '') { return new ErrorType(); } - // a method call with a concrete name on the name-pinned scope - // is synthetic. - $truthyScope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); - - return $nodeScopeResolver->priceSyntheticOnDemand( + return $resolveMethod( + $constantString->getValue(), new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args), - $truthyScope, ); }, $nameType->getConstantStrings()), ); From e52903186c732091cac3f81d97ee884d1e06b893 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:06:57 +0200 Subject: [PATCH 205/378] Resolve static call return types without the asking scope Mirror the method-call change for static calls: read the class-expression type from the operand result via getType()/getNativeType() (or resolveTypeByName on beforeScope for a Name class), and resolve the method reflection and dynamic return type extensions on the captured beforeScope. The dynamic Foo::{$name}() branch resolves each constant name on beforeScope. resolveReturnType takes the reflection scope and a nativeTypesPromoted flag instead of the asking scope. --- .../ExprHandler/StaticCallHandler.php | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 33950a04564..02576b71ae5 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -245,7 +245,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // re-derived from the already-processed argument results on the asking scope. $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( $nodeScopeResolver, - $s, + $beforeScope, + $s->nativeTypesPromoted, $expr, $classResult, $nameResult, @@ -296,7 +297,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // readStoredOrPriceOnDemand, never re-running processArgs) - asking // Scope::getType() for the StaticCall here would re-enter this handler on // demand, as its final result is not stored yet. - $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); + $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $staticCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -398,78 +399,76 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * * @param StaticCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, StaticCall $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { + $classType = $classResult !== null + ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) + : null; // a call on a nullsafe chain whose class-receiver is currently nullable // short-circuits to null - the class result carries whether the chain // contains a ?-> (a plain nullable receiver does not propagate). $shortCircuit = static fn (Type $type): Type => $expr->class instanceof Expr && $classResult !== null && $classResult->containsNullsafe() - && TypeCombinator::containsNull($classResult->getTypeForScope($scope)) + && $classType !== null + && TypeCombinator::containsNull($classType) ? TypeCombinator::addNull($type) : $type; - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { + // the method reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope); the class- + // expression type is read from the operand result above. + $resolveStaticMethod = function (string $methodName, StaticCall $staticCall) use ($reflectionScope, $nativeTypesPromoted, $classType, $nodeScopeResolver, $expr, $preResolvedAcceptor): Type { + if ($nativeTypesPromoted) { if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $scope->resolveTypeByName($expr->class); + $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = $classResult !== null - ? $classResult->getNativeTypeForScope($scope) - : $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->class, $scope); + $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->class, $reflectionScope); } - $methodReflection = $scope->getMethodReflection( - $staticMethodCalledOnType, - $expr->name->name, - ); + $methodReflection = $reflectionScope->getMethodReflection($staticMethodCalledOnType, $methodName); if ($methodReflection === null) { - $callType = new ErrorType(); - } else { - $callType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + return new ErrorType(); } - return $shortCircuit($callType); + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $scope->resolveTypeByName($expr->class); + $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $classType = $classResult !== null - ? $classResult->getTypeForScope($scope) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); - $staticMethodCalledOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); + $resolvedClassType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $reflectionScope); + $staticMethodCalledOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); } - $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( - $scope, + return $this->methodCallReturnTypeHelper->methodCallReturnType( + $reflectionScope, $staticMethodCalledOnType, - $expr->name->toString(), - $expr, + $methodName, + $staticCall, $preResolvedAcceptor, - ); - if ($callType === null) { - $callType = new ErrorType(); - } + ) ?? new ErrorType(); + }; - return $shortCircuit($callType); + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveStaticMethod($expr->name->toString(), $expr)); } - $nameType = $nameResult !== null ? $nameResult->getTypeForScope($scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); + // dynamic static call Foo::{$name}(): resolve each possible name on the + // reflection scope. The asking scope is not narrowed per name, so such + // calls can be less precise. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $reflectionScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static function ($constantString) use ($expr, $scope, $nodeScopeResolver): Type { + ...array_map(static function ($constantString) use ($expr, $resolveStaticMethod): Type { if ($constantString->getValue() === '') { return new ErrorType(); } - // a static call with a concrete name on the name-pinned scope - // is synthetic. - $truthyScope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); - - return $nodeScopeResolver->priceSyntheticOnDemand( + return $resolveStaticMethod( + $constantString->getValue(), new StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args), - $truthyScope, ); }, $nameType->getConstantStrings()), ); From c9108bab7b869d8f0e1ce07ec78daa5c2eb5e041 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:25:28 +0200 Subject: [PATCH 206/378] Resolve function call return types without the asking scope Mirror the method-call change for function calls: resolveReturnType now takes the reflection scope and a nativeTypesPromoted flag instead of the asking scope. The name/arg operands are read from their results via getType()/getNativeType(), and the function reflection lookup, callable-parameter acceptors, ArgumentsNormalizer and the dynamic function return type extensions all run on the captured beforeScope (the lexical context). The throw-point caller passes the processing scope with the phpdoc flag. This drops per-constant-array-item reanalysis for closures whose body is a function call inside array_map (array_map(fn($v) => strval($v), ...)), so one array-map assertion is relaxed. --- src/Analyser/ExprHandler/FuncCallHandler.php | 43 +++++++++++--------- tests/PHPStan/Analyser/nsrt/array-map.php | 6 +-- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 8aaf70a8f1f..d364cb54840 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -324,7 +324,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // already-processed argument results on the asking scope. $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( $nodeScopeResolver, - $s, + $beforeScope, + $s->nativeTypesPromoted, $expr, $nameResult, $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, @@ -397,7 +398,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // never re-running processArgs) - asking Scope::getType() for the // FuncCall here would re-enter this handler on demand, as its result is // not stored yet. - $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, $expr, $nameResult, $resolvedParametersAcceptor, $argsResult); + $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $nameResult, $resolvedParametersAcceptor, $argsResult); // The early structural check above (line ~180) only sees the unresolved // acceptor return type; a conditional-return never (e.g. // `($x is Foo ? never : string)`) only resolves to never once the actual @@ -913,25 +914,29 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra * * @param FuncCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ArgsResult $argsResult): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ArgsResult $argsResult): Type { // the operands/arguments were processed during processExpr; read their // already computed results instead of re-walking via Scope::getType(). - // Synthetic nodes the resolver builds (e.g. Clone_, call_user_func's inner - // FuncCall) are priced on demand by the same helper. - $getType = static function (Expr $e) use ($expr, $nameResult, $scope, $nodeScopeResolver, $argsResult): Type { + // The function reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope). Synthetic nodes the + // resolver builds (e.g. Clone_, call_user_func's inner FuncCall) are priced + // on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $nameResult, $reflectionScope, $nodeScopeResolver, $argsResult, $nativeTypesPromoted): Type { if ($nameResult !== null && $e === $expr->name) { - return $nameResult->getTypeForScope($scope); + return $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); } $argResult = $argsResult->getArgResult($e); if ($argResult !== null) { - return $argResult->getTypeForScope($scope); + return $nativeTypesPromoted ? $argResult->getNativeType() : $argResult->getType(); } // Synthetic nodes (call_user_func's inner FuncCall, clone-with's Clone_) // have no captured arg result; they are priced on demand. - return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + return $nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $reflectionScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $reflectionScope); }; if ($expr->name instanceof Expr) { @@ -943,7 +948,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin if ($preResolvedAcceptor !== null) { $parametersAcceptor = $preResolvedAcceptor; } else { - $variants = $calledOnType->getCallableParametersAcceptors($scope); + $variants = $calledOnType->getCallableParametersAcceptors($reflectionScope); $parametersAcceptor = count($variants) === 1 ? $variants[0] : ParametersAcceptorSelector::combineAcceptors($variants); @@ -963,9 +968,9 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin } $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); - if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $scope)) { - $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); - $resolvedType = $this->getDynamicFunctionReturnType($scope, $normalizedNode, $functionReflection); + if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $reflectionScope)) { + $functionReflection = $this->reflectionProvider->getFunction($functionName, $reflectionScope); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection); if ($resolvedType !== null) { return $resolvedType; } @@ -974,17 +979,17 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin return $parametersAcceptor->getReturnType(); } - if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + if (!$this->reflectionProvider->hasFunction($expr->name, $reflectionScope)) { return new ErrorType(); } - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - if ($scope->nativeTypesPromoted) { + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $reflectionScope); + if ($nativeTypesPromoted) { return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); } if ($functionReflection->getName() === 'call_user_func') { - $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $scope); + $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $reflectionScope); if ($result !== null) { [, $innerFuncCall] = $result; @@ -993,7 +998,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin } if ($functionReflection->getName() === 'call_user_func_array') { - $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $scope); + $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $reflectionScope); if ($result !== null) { [, $innerFuncCall] = $result; @@ -1035,7 +1040,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin return $cloneType; } - $resolvedType = $this->getDynamicFunctionReturnType($scope, $normalizedNode, $functionReflection); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection); if ($resolvedType !== null) { return $resolvedType; } diff --git a/tests/PHPStan/Analyser/nsrt/array-map.php b/tests/PHPStan/Analyser/nsrt/array-map.php index 6ca8d158171..fe86a874bf6 100644 --- a/tests/PHPStan/Analyser/nsrt/array-map.php +++ b/tests/PHPStan/Analyser/nsrt/array-map.php @@ -94,9 +94,9 @@ public function doFoo(): void assertType("array{'0', '1'}", array_map('strval', $a)); assertType("array{'0', '1'}", array_map(strval(...), $a)); - assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a)); - // per-constant-array-item closure reanalysis is not done, so the cast is - // resolved once over the unioned element type rather than per item. + // per-constant-array-item closure reanalysis is not done, so the closure + // body is resolved once over the unioned element type rather than per item. + assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a)); assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a)); } From 69c1b08f31e6bf97f06e72d679e9b0f59c35e07e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:33:54 +0200 Subject: [PATCH 207/378] Resolve nullsafe fetch/call short-circuit types without the asking scope The nullsafe ?-> / ?->() type callbacks read the non-null inner result via getTypeForScope($s) and applied the null-removal narrowing (var !== null) to the asking scope. Read the inner result via getType()/getNativeType() and apply the null-removal narrowing to the captured beforeScope (the evaluation point), pricing the synthetic fetch/call with the native-aware on-demand helper. The callback now uses the scope only for the native flag. --- .../ExprHandler/NullsafeMethodCallHandler.php | 15 ++++++++++----- .../ExprHandler/NullsafePropertyFetchHandler.php | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 451eb8bcff3..4b8650d879a 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -95,19 +95,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } if (!TypeCombinator::containsNull($receiverType)) { - return $exprResult->getTypeForScope($s); + return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // the plain method call on the null-removed scope is synthetic. - $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); + // the plain method call on the null-removed scope is synthetic; the + // null-removal narrowing is applied to beforeScope (the evaluation point), + // not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); return TypeCombinator::union( - $nodeScopeResolver->priceSyntheticOnDemand(new MethodCall($expr->var, $expr->name, $expr->args), $truthyScope), + $s->nativeTypesPromoted + ? $nodeScopeResolver->priceSyntheticOnDemandNative($methodCall, $truthyScope) + : $nodeScopeResolver->priceSyntheticOnDemand($methodCall, $truthyScope), new NullType(), ); }; diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index b1009d7b2fc..8f87a2753d8 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -75,19 +75,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType): Type { + $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } if (!TypeCombinator::containsNull($receiverType)) { - return $exprResult->getTypeForScope($s); + return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // the plain property fetch on the null-removed scope is synthetic. - $truthyScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); + // the plain property fetch on the null-removed scope is synthetic; the + // null-removal narrowing is applied to beforeScope (the evaluation point), + // not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + $propertyFetch = new PropertyFetch($expr->var, $expr->name); return TypeCombinator::union( - $nodeScopeResolver->priceSyntheticOnDemand(new PropertyFetch($expr->var, $expr->name), $truthyScope), + $s->nativeTypesPromoted + ? $nodeScopeResolver->priceSyntheticOnDemandNative($propertyFetch, $truthyScope) + : $nodeScopeResolver->priceSyntheticOnDemand($propertyFetch, $truthyScope), new NullType(), ); }; From 26b6c9ab4c25b5cdb477dca07e64b1f72ba50f03 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:45:39 +0200 Subject: [PATCH 208/378] Read assignment and class-constant-fetch type callbacks via getType/getNativeType Prepare these type callbacks for the scope-free signature: the assignment callbacks read the assigned value via getType()/getNativeType() (the no-result fallback prices the RHS on beforeScope), and the class-constant-fetch callback reads its class-expression operand via getType()/getNativeType(). The scope param is now used only for the native flag. --- src/Analyser/ExprHandler/AssignHandler.php | 12 ++++++++++-- src/Analyser/ExprHandler/ClassConstFetchHandler.php | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 137ba77dbde..5c444fa19ee 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -169,7 +169,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static fn ($scope) => $result->getTypeForScope($scope), + typeCallback: static fn (MutatingScope $s): Type => $s->nativeTypesPromoted ? $result->getNativeType() : $result->getType(), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); }, @@ -221,7 +221,15 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - typeCallback: static fn (MutatingScope $s): Type => $assignedExprResult !== null ? $assignedExprResult->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s), + typeCallback: static function (MutatingScope $s) use ($assignedExprResult, $nodeScopeResolver, $expr, $beforeScope): Type { + if ($assignedExprResult !== null) { + return $s->nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(); + } + + return $s->nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->expr, $beforeScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $beforeScope); + }, specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 890224055be..8b68ea81d8f 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -102,7 +102,7 @@ static function (Expr $e) use ($classResult, $scope): Type { throw new ShouldNotHappenException(); } - return $classResult->getTypeForScope($scope); + return $scope->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); }, ); }, From 222ed3e06c109aee35bc4d114931855729e65ba5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:49:42 +0200 Subject: [PATCH 209/378] Read instanceof and coalesce type callbacks without the asking scope The instanceof callback reads its expression and class operands via getType()/getNativeType(). The coalesce callback runs its isset resolution and left-is-set narrowing on the captured beforeScope, and reads the right side from its own result (processed on the left-is-null scope). Both callbacks now use the scope only for the native flag. --- src/Analyser/ExprHandler/CoalesceHandler.php | 16 +++++++++------- src/Analyser/ExprHandler/InstanceofHandler.php | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index bc7f0cb56d2..68ddd2403bf 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -93,10 +93,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $rightScope, $nodeScopeResolver): Type { + typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { $issetLeftExpr = new Expr\Isset_([$expr->left]); - $result = $condResult->getIssetabilityResolution($s, false)->isSet(static function (Type $type): ?bool { + // the isset resolution and the left-is-set narrowing run on + // beforeScope (the evaluation point), not the asking scope. + $result = $condResult->getIssetabilityResolution($beforeScope, false)->isSet(static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; @@ -106,16 +108,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }); if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()); + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()); } - // the right side was processed on the left-is-null scope - that - // captured scope is the evaluation point - $rightType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $rightScope->doNotTreatPhpDocTypesAsCertain() : $rightScope); + // the right side was processed on the left-is-null scope, so its own + // result is the evaluation point. + $rightType = $s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); if ($result === null) { return TypeCombinator::union( - TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()), + TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()), $rightType, ); } diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 8e3e5c664d6..b436582d088 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -113,7 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $classResult, $isInTrait, $nameClassType): Type { - $expressionType = $exprResult->getTypeForScope($s); + $expressionType = $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); if ( $isInTrait && TypeUtils::findThisType($expressionType) !== null @@ -137,7 +137,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($classResult === null) { throw new ShouldNotHappenException(); } - $classNameType = $classResult->getTypeForScope($s); + $classNameType = $s->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); $result = $classNameType->toObjectTypeForInstanceofCheck(); $classType = $result->type; $uncertainty = $result->uncertainty; From 2909134a49313cab87b8beb16afc8f499370c578 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 14:52:42 +0200 Subject: [PATCH 210/378] Read assign-op type callback without the asking scope The assign-op callback's operand reader and the ??= coalesce branch (current expression storage + on-demand pricing) now run on the captured beforeScope, with native-vs-phpdoc selected by the native flag. The callback uses the scope only for the native flag. --- src/Analyser/ExprHandler/AssignOpHandler.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 4a1e3cba952..c4f3138c24f 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -84,7 +84,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read // them instead of re-walking via Scope::getType(). - $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); + $getType = static fn (Expr $e): Type => $s->nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); if ($expr instanceof Expr\AssignOp\Coalesce) { // The coalesce is synthetic; price it on demand. The ??= left is stored @@ -94,10 +96,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // branch, losing the optional offset natively (bug-13623). $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); - $coalesceStorage = ($s->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); + $coalesceStorage = ($beforeScope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); - return $nodeScopeResolver->processExprOnDemand($coalesce, $s, $coalesceStorage)->getTypeForScope($s); + $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); + + return $s->nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); } if ($expr instanceof Expr\AssignOp\Concat) { From 8a4f8a2fdee96ceafcb91e092d4d1806a77ad70f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 15:36:55 +0200 Subject: [PATCH 211/378] Pass the iteratee types into enterForeach instead of re-reading them NodeScopeResolver already resolves the foreach iteratee's type (and native type) by reprocessing the iteratee on the (possibly non-empty-narrowed) originalScope. Pass those into MutatingScope::enterForeach instead of having it re-read $originalScope->getType()/ getNativeType() on the iteratee - removing two Scope::getType calls from the foreach path. --- src/Analyser/MutatingScope.php | 4 +--- src/Analyser/NodeScopeResolver.php | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 44cea2d7323..6134dcf0a86 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2553,10 +2553,8 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName, bool $valueByRef): self + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); $scope = $this->assignVariable( diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 9f70b034cdd..5c70c04231c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -5204,6 +5204,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $scope = $scope->enterForeach( $originalScope, $stmt->expr, + $iterateeType, + $nativeIterateeType, $stmt->valueVar->name, $keyVarName, $stmt->byRef, From b1d0c60ed998b8d7ae66631970989e2cd067ed54 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 16:49:18 +0200 Subject: [PATCH 212/378] Pass the iteratee types into enterForeachKey instead of re-reading them Mirror the enterForeach change for enterForeachKey: take the already-resolved iteratee type and native type as arguments instead of re-reading $originalScope->getType()/ getNativeType() on the iteratee, removing two more Scope::getType calls from the foreach key path. --- src/Analyser/MutatingScope.php | 7 ++----- src/Analyser/NodeScopeResolver.php | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 6134dcf0a86..9bd5c8f74f8 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2583,7 +2583,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee ); } if ($keyName !== null) { - $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( @@ -2597,11 +2597,8 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee return $scope; } - public function enterForeachKey(self $originalScope, Expr $iteratee, string $keyName): self + public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); - $keyType = $originalScope->getIterableKeyType($iterateeType); $nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 5c70c04231c..c123e8ee747 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -5230,7 +5230,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto if ( $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ) { - $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $stmt->keyVar->name); + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name); $vars[] = $stmt->keyVar->name; } elseif ($stmt->keyVar !== null) { $scope = $this->processVirtualAssign( From d1549baff90d0024e7e907f8837688ee0bb8bc67 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 20:46:14 +0200 Subject: [PATCH 213/378] Make the ExpressionResult typeCallback resolve from a native flag, not a scope Every typeCallback closure now takes (bool $nativeTypesPromoted) instead of a MutatingScope. It reads operand types from its captured child ExpressionResults (getType()/getNativeType()) and any scope-dependent lookups from the captured beforeScope, so an expression's type no longer depends on the scope it is asked on - the single-pass goal that lets getTypeForScope() be memoized later. ExpressionResult invokes the callback with false for getType(), true for getNativeType(), and $scope->nativeTypesPromoted for getTypeForScope(). Enabling changes that land with it: - VariableHandler reads the variable from its captured beforeScope. - The assign/narrowing machinery (specifyExpressionType, addTypeToExpression, removeTypeFromExpression, unsetExpression) reads narrowable subjects from the scope's tracked state via a new getScopeStateType helper instead of Scope::getType (which would route back through the now scope-independent callbacks and read the stale beforeScope value). - A few remaining engine Scope::getType callers move off it: getKeepVoidType, expressionTypeIsUnchangeable, getRealParameterDefaultValues (via InitializerExprTypeResolver) and IssetabilityDescriptor (via getVariableType). --- .../ExprHandler/ArrayDimFetchHandler.php | 8 +- src/Analyser/ExprHandler/ArrayHandler.php | 10 +- src/Analyser/ExprHandler/AssignHandler.php | 10 +- src/Analyser/ExprHandler/AssignOpHandler.php | 6 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 14 ++- .../ExprHandler/BitwiseNotHandler.php | 4 +- .../ExprHandler/BooleanAndHandler.php | 6 +- .../ExprHandler/BooleanNotHandler.php | 4 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 6 +- src/Analyser/ExprHandler/CastHandler.php | 6 +- .../ExprHandler/CastStringHandler.php | 4 +- .../ExprHandler/ClassConstFetchHandler.php | 6 +- src/Analyser/ExprHandler/CloneHandler.php | 4 +- src/Analyser/ExprHandler/CoalesceHandler.php | 4 +- .../ExprHandler/ConstFetchHandler.php | 2 +- src/Analyser/ExprHandler/EmptyHandler.php | 4 +- .../ExprHandler/ErrorSuppressHandler.php | 2 +- src/Analyser/ExprHandler/EvalHandler.php | 2 +- src/Analyser/ExprHandler/ExitHandler.php | 2 +- src/Analyser/ExprHandler/FuncCallHandler.php | 6 +- src/Analyser/ExprHandler/IncludeHandler.php | 2 +- .../ExprHandler/InstanceofHandler.php | 6 +- .../ExprHandler/InterpolatedStringHandler.php | 4 +- src/Analyser/ExprHandler/IssetHandler.php | 4 +- src/Analyser/ExprHandler/MatchHandler.php | 6 +- .../ExprHandler/MethodCallHandler.php | 6 +- src/Analyser/ExprHandler/NewHandler.php | 6 +- .../ExprHandler/NullsafeMethodCallHandler.php | 8 +- .../NullsafePropertyFetchHandler.php | 8 +- src/Analyser/ExprHandler/PipeHandler.php | 4 +- src/Analyser/ExprHandler/PostDecHandler.php | 2 +- src/Analyser/ExprHandler/PostIncHandler.php | 2 +- src/Analyser/ExprHandler/PreDecHandler.php | 8 +- src/Analyser/ExprHandler/PreIncHandler.php | 8 +- src/Analyser/ExprHandler/PrintHandler.php | 2 +- .../ExprHandler/PropertyFetchHandler.php | 12 +-- src/Analyser/ExprHandler/ShellExecHandler.php | 2 +- .../ExprHandler/StaticCallHandler.php | 6 +- .../StaticPropertyFetchHandler.php | 10 +- src/Analyser/ExprHandler/TernaryHandler.php | 11 +- src/Analyser/ExprHandler/ThrowHandler.php | 2 +- .../ExprHandler/UnaryMinusHandler.php | 6 +- src/Analyser/ExprHandler/UnaryPlusHandler.php | 4 +- src/Analyser/ExprHandler/VariableHandler.php | 17 +-- .../Virtual/AlwaysRememberedExprHandler.php | 2 +- .../Virtual/ExistingArrayDimFetchHandler.php | 2 +- .../Virtual/FunctionCallableNodeHandler.php | 2 +- .../InstantiationCallableNodeHandler.php | 2 +- .../ExprHandler/Virtual/IssetExprHandler.php | 2 +- .../Virtual/MethodCallableNodeHandler.php | 2 +- .../Virtual/NativeTypeExprHandler.php | 2 +- .../Virtual/PossiblyImpureCallExprHandler.php | 2 +- .../SetExistingOffsetValueTypeExprHandler.php | 6 +- .../Virtual/SetOffsetValueTypeExprHandler.php | 6 +- .../StaticMethodCallableNodeHandler.php | 2 +- .../ExprHandler/Virtual/TypeExprHandler.php | 2 +- .../Virtual/UnsetOffsetExprHandler.php | 2 +- src/Analyser/ExprHandler/YieldFromHandler.php | 4 +- src/Analyser/ExpressionResult.php | 12 +-- src/Analyser/ExpressionResultFactory.php | 2 +- src/Analyser/IssetabilityDescriptor.php | 2 +- src/Analyser/MutatingScope.php | 101 +++++++++++++++--- src/Analyser/NodeScopeResolver.php | 2 +- 63 files changed, 244 insertions(+), 169 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index c2aa33ff598..5e39abd7814 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -108,8 +108,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), - typeCallback: static function (MutatingScope $s) use ($varResult, $dimResult, $offsetGetResult): Type { - $offsetAccessibleType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + typeCallback: static function (bool $nativeTypesPromoted) use ($varResult, $dimResult, $offsetGetResult): Type { + $offsetAccessibleType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) ? TypeCombinator::addNull($type) : $type; @@ -119,10 +119,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && !$offsetAccessibleType->isArray()->yes() && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() ) { - return $shortCircuit(($s->nativeTypesPromoted ? $offsetGetResult->getNativeType() : $offsetGetResult->getType())); + return $shortCircuit(($nativeTypesPromoted ? $offsetGetResult->getNativeType() : $offsetGetResult->getType())); } - return $shortCircuit($offsetAccessibleType->getOffsetValueType(($s->nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); + return $shortCircuit($offsetAccessibleType->getOffsetValueType(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); }, specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index efd1ad3a028..094bea8a0c4 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -88,14 +88,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $s) use ($expr, $itemResults): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $itemResults, $beforeScope): Type { // each item type was captured at its own evaluation point in the // sequence - resolving all items on any single scope (the old world) // cannot handle items with side effects like [$b = 1, $b + 1, $b++] - $type = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemResults, $s): Type { + $type = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemResults, $nativeTypesPromoted): Type { $id = spl_object_id($inner); if (array_key_exists($id, $itemResults)) { - return $s->nativeTypesPromoted + return $nativeTypesPromoted ? $itemResults[$id]->getNativeType() : $itemResults[$id]->getType(); } @@ -113,11 +113,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex [new Arg($expr)], ); if ( - $s->hasExpressionType($isCallableCall)->yes() + $beforeScope->hasExpressionType($isCallableCall)->yes() // read the narrowed type from expressionTypes directly (the // synthetic is_callable() call was never processed as a child), // mirroring ConstFetchHandler's narrowed-constant lookup - && $s->expressionTypes[$s->getNodeKey($isCallableCall)]->getType()->isTrue()->yes() + && $beforeScope->expressionTypes[$beforeScope->getNodeKey($isCallableCall)]->getType()->isTrue()->yes() ) { $type = TypeCombinator::intersect($type, new CallableType()); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 5c444fa19ee..b996245638c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -169,7 +169,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static fn (MutatingScope $s): Type => $s->nativeTypesPromoted ? $result->getNativeType() : $result->getType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $result->getNativeType() : $result->getType(), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); }, @@ -221,12 +221,12 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - typeCallback: static function (MutatingScope $s) use ($assignedExprResult, $nodeScopeResolver, $expr, $beforeScope): Type { + typeCallback: static function (bool $nativeTypesPromoted) use ($assignedExprResult, $nodeScopeResolver, $expr, $beforeScope): Type { if ($assignedExprResult !== null) { - return $s->nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(); + return $nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(); } - return $s->nativeTypesPromoted + return $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->expr, $beforeScope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $beforeScope); }, @@ -669,7 +669,7 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $s)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $s)), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimFetch->var, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope)), specifyTypesCallback: static fn () => new SpecifiedTypes(), )); $scope = $result->getScope(); diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index c4f3138c24f..55559f12fef 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -79,12 +79,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); } - $typeCallback = function (MutatingScope $s) use ($expr, $nodeScopeResolver, $beforeScope): Type { + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read // them instead of re-walking via Scope::getType(). - $getType = static fn (Expr $e): Type => $s->nativeTypesPromoted + $getType = static fn (Expr $e): Type => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); @@ -101,7 +101,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); - return $s->nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); + return $nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); } if ($expr instanceof Expr\AssignOp\Concat) { diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index b38c3aa880e..704ca888bbc 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -117,20 +117,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { + // the comparison helpers (resolveEqualType / RicherScopeGetTypeHelper) + // read the operand types off the evaluation scope - native-promote it + // here so the native flavour is honoured. + $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; // the operands were processed during processExpr; read their already // computed results instead of re-walking via Scope::getType(). // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are // priced on demand by the same helper. - $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver): Type { + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $nativeTypesPromoted, $beforeScope, $nodeScopeResolver): Type { if ($e === $expr->left) { - return $leftResult->getTypeForScope($scope); + return ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType()); } if ($e === $expr->right) { - return $rightResult->getTypeForScope($scope); + return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); } - return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + return $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); }; if ($expr instanceof BinaryOp\Smaller) { diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index cf0bd37aab5..232bd9372fa 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -51,9 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { if ($e === $expr->expr) { - return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + return ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 6a257451712..a20cfeee076 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -97,8 +97,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), - typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftTruthyScope): Type { - $leftBooleanType = ($s->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); + typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { + $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isFalse()->yes()) { return new ConstantBooleanType(false); } @@ -107,7 +107,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the left's side effects (assignments, by-ref writes) - that // captured scope is the evaluation point, no re-walk and no // depth cap needed - $rightBooleanType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $leftTruthyScope->doNotTreatPhpDocTypesAsCertain() : $leftTruthyScope)->toBoolean(); + $rightBooleanType = ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean(); if ($rightBooleanType->isFalse()->yes()) { return new ConstantBooleanType(false); } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 11c6a97c4a9..59f15900cf9 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -53,8 +53,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static function (MutatingScope $s) use ($exprResult): Type { - $exprBooleanType = ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $exprBooleanType = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); if ($exprBooleanType->isTrue()->yes()) { return new ConstantBooleanType(false); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index e1678e906fd..60e53fc37dd 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -147,8 +147,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), - typeCallback: static function (MutatingScope $s) use ($leftResult, $rightResult, $leftFalseyScope): Type { - $leftBooleanType = ($s->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); + typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { + $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isTrue()->yes()) { return new ConstantBooleanType(true); } @@ -157,7 +157,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the left's side effects (assignments, by-ref writes) - that // captured scope is the evaluation point, no re-walk and no // depth cap needed - $rightBooleanType = $rightResult->getTypeForScope($s->nativeTypesPromoted ? $leftFalseyScope->doNotTreatPhpDocTypesAsCertain() : $leftFalseyScope)->toBoolean(); + $rightBooleanType = ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean(); if ($rightBooleanType->isTrue()->yes()) { return new ConstantBooleanType(true); } diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index 4cf47fb5473..a236cff2376 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -61,14 +61,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: function (MutatingScope $s) use ($expr, $exprResult): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $exprResult): Type { if ($expr instanceof Cast\Unset_) { return new NullType(); } - return $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { + return $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { if ($e === $expr->expr) { - return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 2e172b139b4..c788cb04a45 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -66,9 +66,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $s): Type => $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($s, $expr, $exprResult): Type { + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { if ($e === $expr->expr) { - return ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + return ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 8b68ea81d8f..6321d4eb7e7 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -86,7 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr, $classResult, $classReflection): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $classResult, $classReflection): Type { if (!$expr->name instanceof Identifier) { return new MixedType(); } @@ -97,12 +97,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classReflection, // getClassConstFetchTypeByReflection only invokes this for $expr->class // when it is an Expr, which is exactly when $classResult exists - static function (Expr $e) use ($classResult, $scope): Type { + static function (Expr $e) use ($classResult, $nativeTypesPromoted): Type { if ($classResult === null) { throw new ShouldNotHappenException(); } - return $scope->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); + return $nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); }, ); }, diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index f35d2d1fe34..2aeec17852e 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -52,8 +52,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { - $cloneType = TypeCombinator::intersect(($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $cloneType = TypeCombinator::intersect(($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); return TypeTraverser::map($cloneType, new CloneTypeTraverser()); }, specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 68ddd2403bf..22175f08bd6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -93,7 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - typeCallback: static function (MutatingScope $s) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { $issetLeftExpr = new Expr\Isset_([$expr->left]); // the isset resolution and the left-is-set narrowing run on @@ -113,7 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the right side was processed on the left-is-null scope, so its own // result is the evaluation point. - $rightType = $s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); if ($result === null) { return TypeCombinator::union( diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 02965694035..2e0e9d2a549 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -55,7 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: function (MutatingScope $scope) use ($expr): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $scope): Type { $constName = (string) $expr->name; $loweredConstName = strtolower($constName); if ($loweredConstName === 'true') { diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 775021a2571..44a03927041 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -63,8 +63,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static function (MutatingScope $s) use ($exprResult): Type { - $result = $exprResult->getIssetabilityResolution($s, false)->notEmpty(); + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult, $beforeScope): Type { + $result = $exprResult->getIssetabilityResolution($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->notEmpty(); if ($result === null) { return new BooleanType(); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 57ab30bddba..c314340c5b7 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 3b992be124a..31b7a571bd8 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -54,7 +54,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index eff4504ddf3..62a0ddb7baa 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -66,7 +66,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static fn (MutatingScope $scope): Type => new NonAcceptingNeverType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d364cb54840..0ab87271c9c 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -322,13 +322,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // (native-types-promoted, on-demand / synthetic pricing, or special cases // inside resolveReturnType), the acceptor is re-derived from the // already-processed argument results on the asking scope. - $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( $nodeScopeResolver, $beforeScope, - $s->nativeTypesPromoted, + $nativeTypesPromoted, $expr, $nameResult, - $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $argsResult, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 2e802104098..6459565a391 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), - typeCallback: static fn (MutatingScope $scope): Type => new MixedType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index b436582d088..e45628e1d3b 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -112,8 +112,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static function (MutatingScope $s) use ($expr, $exprResult, $classResult, $isInTrait, $nameClassType): Type { - $expressionType = $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $classResult, $isInTrait, $nameClassType): Type { + $expressionType = $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); if ( $isInTrait && TypeUtils::findThisType($expressionType) !== null @@ -137,7 +137,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($classResult === null) { throw new ShouldNotHappenException(); } - $classNameType = $s->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); + $classNameType = $nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); $result = $classNameType->toObjectTypeForInstanceofCheck(); $classType = $result->type; $uncertainty = $result->uncertainty; diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index b6baa730146..b784767573d 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -79,14 +79,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (MutatingScope $scope) use ($expr, $partResults): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $partResults): Type { $resultType = null; foreach ($expr->parts as $part) { if ($part instanceof InterpolatedStringPart) { $partType = new ConstantStringType($part->value); } else { $partResult = $partResults[spl_object_id($part)]; - $partType = ($scope->nativeTypesPromoted ? $partResult->getNativeType() : $partResult->getType())->toString(); + $partType = ($nativeTypesPromoted ? $partResult->getNativeType() : $partResult->getType())->toString(); } if ($resultType === null) { $resultType = $partType; diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 7ab6c5f515c..7dd2f5fd08e 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -139,10 +139,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static function (MutatingScope $s) use ($varResults): Type { + typeCallback: static function (bool $nativeTypesPromoted) use ($varResults, $beforeScope): Type { $issetResult = true; foreach ($varResults as $varResult) { - $result = $varResult->getIssetabilityResolution($s, false)->isSet(static function (Type $type): ?bool { + $result = $varResult->getIssetabilityResolution($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 9e099a9a218..f0178e2db55 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -542,11 +542,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // is narrowed to that arm's condition - those captured scopes are the // evaluation points, so the result type is just the union of the arm // body types, no re-walk of the arms needed. - typeCallback: static function (MutatingScope $s) use ($expr, $armTypeResults): Type { + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $armTypeResults): Type { $keepVoid = $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true; $types = []; foreach ($armTypeResults as [$armResult, $bodyScope, $armBody]) { - if ($s->nativeTypesPromoted) { + if ($nativeTypesPromoted) { $bodyScope = $bodyScope->doNotTreatPhpDocTypesAsCertain(); } if ($keepVoid) { @@ -555,7 +555,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // instead of transforming it to null. $types[] = $bodyScope->getKeepVoidType($armBody); } else { - $types[] = $armResult->getTypeForScope($bodyScope); + $types[] = ($nativeTypesPromoted ? $armResult->getNativeType() : $armResult->getType()); } } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 24f8b668ec8..e3bb5b761b4 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -172,14 +172,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // evolving scope (type-driven, generics resolved). When null // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is // re-derived from the already-processed argument results on the asking scope. - $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( $nodeScopeResolver, $beforeScope, - $s->nativeTypesPromoted, + $nativeTypesPromoted, $expr, $varResult, $nameResult, - $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 5ea7c84e8db..4eb414c1f55 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -231,11 +231,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // parameters from constructor args). When null (native-types-promoted, or // on-demand / synthetic pricing), resolveReturnType() re-selects a structural // acceptor from the args on the asking scope. - $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( $nodeScopeResolver, - $s, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, - $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $s, diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 4b8650d879a..678e6125d6e 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -95,12 +95,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } if (!TypeCombinator::containsNull($receiverType)) { - return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } // the plain method call on the null-removed scope is synthetic; the @@ -110,7 +110,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); return TypeCombinator::union( - $s->nativeTypesPromoted + $nativeTypesPromoted ? $nodeScopeResolver->priceSyntheticOnDemandNative($methodCall, $truthyScope) : $nodeScopeResolver->priceSyntheticOnDemand($methodCall, $truthyScope), new NullType(), @@ -154,7 +154,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return (new SpecifiedTypes())->setRootExpr($expr); } - $nullsafeType = $nullsafeTypeCallback($s); + $nullsafeType = $nullsafeTypeCallback($s->nativeTypesPromoted); if ($context->true()) { $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); } else { diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 8f87a2753d8..dfb01d2d559 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -75,12 +75,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (MutatingScope $s) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } if (!TypeCombinator::containsNull($receiverType)) { - return $s->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } // the plain property fetch on the null-removed scope is synthetic; the @@ -90,7 +90,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $propertyFetch = new PropertyFetch($expr->var, $expr->name); return TypeCombinator::union( - $s->nativeTypesPromoted + $nativeTypesPromoted ? $nodeScopeResolver->priceSyntheticOnDemandNative($propertyFetch, $truthyScope) : $nodeScopeResolver->priceSyntheticOnDemand($propertyFetch, $truthyScope), new NullType(), @@ -134,7 +134,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return (new SpecifiedTypes())->setRootExpr($expr); } - $nullsafeType = $nullsafeTypeCallback($s); + $nullsafeType = $nullsafeTypeCallback($s->nativeTypesPromoted); if ($context->true()) { $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); } else { diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 534c280d19e..05390a63c03 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -89,7 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), specifyTypesCallback: static fn () => new SpecifiedTypes(), )); } @@ -105,7 +105,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), // the pipe evaluates to its rewritten call - read that child's result - typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $callResult->getNativeType() : $callResult->getType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callResult->getNativeType() : $callResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 224573676a9..b466ea9258d 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), // post-decrement evaluates to the variable's pre-mutation value - typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 09cd3014b03..94c587b1e10 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), // post-increment evaluates to the variable's pre-mutation value - typeCallback: static fn (MutatingScope $s): Type => ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 5ea9b9b396b..7c7d4d27271 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -60,8 +60,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { - $varType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { + $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -106,9 +106,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $one = new Int_(1); - return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { + return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { if ($e === $expr->var) { - return ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); } if ($e === $one) { return new ConstantIntegerType(1); diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index e40d5bf727e..b39f30bc872 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -61,8 +61,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $typeCallback = function (MutatingScope $s) use ($expr, $varResult): Type { - $varType = ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { + $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -107,9 +107,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $one = new Int_(1); - return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($s, $expr, $varResult, $one): Type { + return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { if ($e === $expr->var) { - return ($s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); } if ($e === $one) { return new ConstantIntegerType(1); diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 8bee2ebdb2d..30a81985eb4 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -62,7 +62,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), - typeCallback: static fn (MutatingScope $scope): Type => new ConstantIntegerType(1), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new ConstantIntegerType(1), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 37fde575dbe..ffbe96769ea 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -95,11 +95,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($varResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $varResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $varResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { // a fetch on a nullsafe chain whose receiver is currently nullable // short-circuits to null - the receiver result carries whether the // chain contains a ?-> (a plain nullable receiver does not propagate) - $receiverType = $s->nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); + $receiverType = $nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($receiverType) ? TypeCombinator::addNull($type) : $type; @@ -107,9 +107,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the property's class/visibility/assign context is lexical, so it // comes from beforeScope; the scope-dependent receiver type is read // from the operand result above. - $reflectionScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; - $resolveProperty = function (string $propertyName) use ($s, $reflectionScope, $receiverType, $expr): Type { - if ($s->nativeTypesPromoted) { + $reflectionScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $resolveProperty = function (string $propertyName) use ($nativeTypesPromoted, $reflectionScope, $receiverType, $expr): Type { + if ($nativeTypesPromoted) { $propertyReflection = $reflectionScope->getInstancePropertyReflection($receiverType, $propertyName); if ($propertyReflection === null) { return new ErrorType(); @@ -133,7 +133,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // from beforeScope. The asking scope is not narrowed per name, so // $obj->{'foo'}-style fetches can be less precise. $nameType = $nameResult !== null - ? ($s->nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( diff --git a/src/Analyser/ExprHandler/ShellExecHandler.php b/src/Analyser/ExprHandler/ShellExecHandler.php index 78df496dad3..7c9f7ce674d 100644 --- a/src/Analyser/ExprHandler/ShellExecHandler.php +++ b/src/Analyser/ExprHandler/ShellExecHandler.php @@ -79,7 +79,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static fn (MutatingScope $scope): Type => TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()), + typeCallback: static fn (bool $nativeTypesPromoted): Type => TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 02576b71ae5..7fd3ea24280 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -243,14 +243,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // evolving scope (type-driven, generics resolved). When null // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is // re-derived from the already-processed argument results on the asking scope. - $typeCallback = fn (MutatingScope $s): Type => $this->resolveReturnType( + $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( $nodeScopeResolver, $beforeScope, - $s->nativeTypesPromoted, + $nativeTypesPromoted, $expr, $classResult, $nameResult, - $s->nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 912e76cf068..99a365edb06 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -96,9 +96,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - typeCallback: function (MutatingScope $s) use ($expr, $classResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $classResult, $nameResult, $nodeScopeResolver, $beforeScope): Type { $classType = $classResult !== null - ? ($s->nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) + ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) : null; $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && $classType !== null && TypeCombinator::containsNull($classType) ? TypeCombinator::addNull($type) @@ -116,8 +116,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $staticPropertyFetchedOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); } - $resolveProperty = function (string $propertyName) use ($s, $reflectionScope, $staticPropertyFetchedOnType, $expr): Type { - if ($s->nativeTypesPromoted) { + $resolveProperty = function (string $propertyName) use ($nativeTypesPromoted, $reflectionScope, $staticPropertyFetchedOnType, $expr): Type { + if ($nativeTypesPromoted) { $propertyReflection = $reflectionScope->getStaticPropertyReflection($staticPropertyFetchedOnType, $propertyName); if ($propertyReflection === null) { return new ErrorType(); @@ -140,7 +140,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // name from beforeScope. The asking scope is not narrowed per name, // so such fetches can be less precise. $nameType = $nameResult !== null - ? ($s->nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 9a7ff090a52..eb6266f253b 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -106,13 +106,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the branches were processed on the cond-truthy/cond-falsey scopes // including the condition's side effects - those captured scopes // are the evaluation points, no re-walk needed - typeCallback: static function (MutatingScope $s) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver): Type { - if ($s->nativeTypesPromoted) { + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $nodeScopeResolver): Type { + if ($nativeTypesPromoted) { $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); - $elseProcessingScope = $elseProcessingScope->doNotTreatPhpDocTypesAsCertain(); } - $booleanConditionType = ($s->nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); - $elseType = $elseResult->getTypeForScope($elseProcessingScope); + $booleanConditionType = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); + $elseType = ($nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType()); if ($expr->if === null || $ifResult === null) { // short-ternary truthy value: the condition read on its own truthy scope // is a different scope than its own, so reprocess it there. @@ -131,7 +130,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } - $ifType = $ifResult->getTypeForScope($ifProcessingScope); + $ifType = ($nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType()); if ($booleanConditionType->isTrue()->yes()) { return $ifType; } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 89a3860f061..af65c3b2a00 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getTypeForScope($scope), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static fn (MutatingScope $scope): Type => new NonAcceptingNeverType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 8feabbf7b57..72542829fe3 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -50,14 +50,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult, $nodeScopeResolver): Type { + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult, $nodeScopeResolver, $scope): Type { if ($e === $expr->expr) { - return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + return ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType // operand) created inside getUnaryMinusType - priced on demand - return $nodeScopeResolver->priceSyntheticOnDemand($e, $scope); + return $nativeTypesPromoted ? $nodeScopeResolver->priceSyntheticOnDemandNative($e, $scope) : $nodeScopeResolver->priceSyntheticOnDemand($e, $scope); }), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index d19c0d38229..a4f2bb4f1c0 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -51,9 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: fn (MutatingScope $scope) => $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static function (Expr $e) use ($scope, $expr, $exprResult): Type { + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { if ($e === $expr->expr) { - return ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + return ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); } throw new ShouldNotHappenException(); diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 2d43e7ae181..3d5f1a1590d 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -56,17 +56,18 @@ public function supports(Expr $expr): bool * target - every stored result for a Variable node must carry a * typeCallback so it can resolve its own type from the stored result. * - * @return Closure(MutatingScope): Type + * @return Closure(bool $nativeTypesPromoted): Type */ - public static function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, ?ExpressionResult $nameResult = null): Closure + public static function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, MutatingScope $beforeScope, ?ExpressionResult $nameResult = null): Closure { - return static function (MutatingScope $s) use ($expr, $nameResult, $nodeScopeResolver): Type { + return static function (bool $nativeTypesPromoted) use ($expr, $nameResult, $nodeScopeResolver, $beforeScope): Type { + $readScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if (is_string($expr->name)) { - if ($s->hasVariableType($expr->name)->no()) { + if ($readScope->hasVariableType($expr->name)->no()) { return new ErrorType(); } - return $s->getVariableType($expr->name); + return $readScope->getVariableType($expr->name); } // this branch is only reached when $expr->name is an Expr, which is @@ -74,11 +75,11 @@ public static function createTypeCallback(Variable $expr, NodeScopeResolver $nod if ($nameResult === null) { throw new ShouldNotHappenException(); } - $nameType = $nameResult->getTypeForScope($s); + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); if (count($nameType->getConstantStrings()) > 0) { $types = []; foreach ($nameType->getConstantStrings() as $constantString) { - $variableScope = $s->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $s, new ExpressionResultStorage())->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy())); + $variableScope = $readScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $readScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($readScope, TypeSpecifierContext::createTruthy())); if ($variableScope->hasVariableType($constantString->getValue())->no()) { $types[] = new ErrorType(); continue; @@ -124,7 +125,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, - typeCallback: self::createTypeCallback($expr, $nodeScopeResolver, $nameResult), + typeCallback: self::createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 9fe636f027d..4241ff8a3d8 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -60,7 +60,7 @@ public function processExpr( isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), - typeCallback: static fn (MutatingScope $scope): Type => $scope->nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), // A type constraint on the remembered wrapper constrains both the wrapper // node (under its __phpstanRemembered(...) key) and the inner expression - diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index c9866404067..59768ebeecc 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $arrayDimFetchResult->getTypeForScope($s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $arrayDimFetchResult->getNativeType() : $arrayDimFetchResult->getType()), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index 80970e939f9..61f41a65e84 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -67,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr, $nameResult), + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $nameResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index c7ee4040a98..2fed5a5cce8 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -63,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted), + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php index 3ba67c45562..f39d92deab9 100644 --- a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($expr->getExpr(), $s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->getExpr(), $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->getExpr(), $scope), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 947d299200c..d75d02529a5 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -68,7 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->resolveType($scope, $expr, $varResult), + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $varResult), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 6ef2fa033f6..10e30c6061f 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -49,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $scope): Type => $scope->nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php index dc94994499f..e396fd2f3e3 100644 --- a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($expr->callExpr, $s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->callExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->callExpr, $scope), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index b3e85e18b17..c83e0dad9a5 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -52,9 +52,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->setExistingOffsetValueType( - $dimResult->getTypeForScope($s), - $valueResult->getTypeForScope($s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->setExistingOffsetValueType( + ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()), + ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), ), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 9cddcbfd431..2ee2f3de31a 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -53,9 +53,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->setOffsetValueType( - $dimResult !== null ? $dimResult->getTypeForScope($s) : null, - $valueResult->getTypeForScope($s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->setOffsetValueType( + $dimResult !== null ? ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()) : null, + ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), ), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index 33583ff0ec5..b254440fa82 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -72,7 +72,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: fn (MutatingScope $scope): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted), + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 1efa5f6db19..5394e117adf 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -49,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $scope): Type => $expr->getExprType(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $expr->getExprType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 09090aac1cd..d08f64cf232 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (MutatingScope $s): Type => $varResult->getTypeForScope($s)->unsetOffset($dimResult->getTypeForScope($s)), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->unsetOffset(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType())), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 0d888d9bd6e..7fcc9fbb34a 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -56,8 +56,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), - typeCallback: static function (MutatingScope $scope) use ($exprResult): Type { - $yieldFromType = ($scope->nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $yieldFromType = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); if ($generatorReturnType instanceof ErrorType) { return new MixedType(); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index cb9ad35415a..62fc49637a7 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -13,7 +13,7 @@ final class ExpressionResult { - /** @var (callable(MutatingScope): Type)|null */ + /** @var (callable(bool): Type)|null */ private $typeCallback; /** @var callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ @@ -39,7 +39,7 @@ final class ExpressionResult /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints - * @param (callable(MutatingScope): Type)|null $typeCallback + * @param (callable(bool): Type)|null $typeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback @@ -198,7 +198,7 @@ public function getType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { - return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope)); + return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); } return $this->cachedType = $this->beforeScope->getType($this->expr); @@ -215,7 +215,7 @@ public function getNativeType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { - return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($this->beforeScope->doNotTreatPhpDocTypesAsCertain())); + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); } return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); @@ -289,7 +289,7 @@ public function getTypeForScope(MutatingScope $scope): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope)); + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->nativeTypesPromoted)); } return $scope->getType($this->expr); @@ -304,7 +304,7 @@ public function getNativeTypeForScope(MutatingScope $scope): Type $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($nativeScope)); + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); } return $scope->getNativeType($this->expr); diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 76e36d8fc1a..00bca2a32f5 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -13,7 +13,7 @@ interface ExpressionResultFactory * @param ImpurePoint[] $impurePoints * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback - * @param (callable(MutatingScope): Type)|null $typeCallback + * @param (callable(bool): Type)|null $typeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index c99351f2a25..501da858ed4 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -83,7 +83,7 @@ public function resolve(MutatingScope $scope, bool $useNativeTypes, Expr $expr): $hasVariable = $scope->hasVariableType($variableName); $valueType = $hasVariable->yes() - ? ($useNativeTypes ? $scope->getNativeType($expr) : $scope->getType($expr)) + ? ($useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain()->getVariableType($variableName) : $scope->getVariableType($variableName)) : new NeverType(); return new IssetabilityResolution(IssetabilityLinkInfo::variable($variableName, $hasVariable, $valueType), null); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9bd5c8f74f8..491c40b032e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1326,10 +1326,10 @@ public function getKeepVoidType(Expr $node): Type ) || $node->isFirstClassCallable() ) ) { - return $this->getType($node); + return $this->getScopeStateType($node); } - $originalType = $this->getType($node); + $originalType = $this->getScopeStateType($node); if (!TypeCombinator::containsNull($originalType)) { return $originalType; } @@ -1809,7 +1809,7 @@ private function getRealParameterDefaultValues(Node\FunctionLike $functionLike): if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { throw new ShouldNotHappenException(); } - $realParameterDefaultValues[$parameter->var->name] = $this->getType($parameter->default); + $realParameterDefaultValues[$parameter->var->name] = $this->initializerExprTypeResolver->getType($parameter->default, InitializerExprContext::fromScope($this)); } return $realParameterDefaultValues; @@ -2322,7 +2322,7 @@ private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): true, ) && isset($expr->getArgs()[0]) - && count($this->getType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 + && count($this->getScopeStateType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 && $type->isTrue()->yes(); } @@ -2963,10 +2963,10 @@ private function unsetExpression(Expr $expr): self { $scope = $this; if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getScopeStateType($expr->var); $dimType = $scope->getType($expr->dim); $unsetType = $exprVarType->unsetOffset($dimType); - $exprVarNativeType = $scope->getNativeType($expr->var); + $exprVarNativeType = $scope->getScopeStateNativeType($expr->var); $dimNativeType = $scope->getNativeType($expr->dim); $unsetNativeType = $exprVarNativeType->unsetOffset($dimNativeType); $scope = $scope->assignExpression($expr->var, $unsetType, $unsetNativeType)->invalidateExpression( @@ -2984,11 +2984,11 @@ private function unsetExpression(Expr $expr): self $expr->var->var, $this->getType($expr->var->var)->setOffsetValueType( $scope->getType($expr->var->dim), - $scope->getType($expr->var), + $scope->getScopeStateType($expr->var), ), $this->getNativeType($expr->var->var)->setOffsetValueType( $scope->getNativeType($expr->var->dim), - $scope->getNativeType($expr->var), + $scope->getScopeStateNativeType($expr->var), ), ); } @@ -2997,6 +2997,77 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + private function getScopeStateType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, false); + } + + private function getScopeStateNativeType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, true); + } + + /** + * Reads a narrowable expression's current type from the scope's tracked + * state (recursing into its operands), instead of routing through the stored + * ExpressionResult callbacks - so it reflects narrowings and assignments + * applied to this scope rather than the expression's original evaluation + * point (where Variable callbacks would read their captured beforeScope). + */ + private function resolveScopeStateType(Expr $expr, bool $native): Type + { + if (!$expr instanceof Variable && $this->hasExpressionType($expr)->yes()) { + return $native ? $this->getNativeType($expr) : $this->getType($expr); + } + + if ($expr instanceof Variable && is_string($expr->name)) { + $scope = $native ? $this->doNotTreatPhpDocTypesAsCertain() : $this; + + return $scope->hasVariableType($expr->name)->no() ? new ErrorType() : $scope->getVariableType($expr->name); + } + + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + return $this->resolveScopeStateType($expr->var, $native) + ->getOffsetValueType($this->resolveScopeStateType($expr->dim, $native)); + } + + if ($expr instanceof PropertyFetch && $expr->name instanceof Identifier) { + $propertyReflection = $this->getInstancePropertyReflection( + $this->resolveScopeStateType($expr->var, $native), + $expr->name->toString(), + ); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if ($native) { + return $propertyReflection->hasNativeType() ? $propertyReflection->getNativeType() : new MixedType(); + } + + return $propertyReflection->getReadableType(); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->name instanceof Node\VarLikeIdentifier) { + $fetchedOnType = $expr->class instanceof Name + ? $this->resolveTypeByName($expr->class) + : TypeCombinator::removeNull($this->resolveScopeStateType($expr->class, $native))->getObjectTypeOrClassStringObjectType(); + $propertyReflection = $this->getStaticPropertyReflection($fetchedOnType, $expr->name->toString()); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if ($native) { + return $propertyReflection->hasNativeType() ? $propertyReflection->getNativeType() : new MixedType(); + } + + return $propertyReflection->getReadableType(); + } + + // genuinely non-narrowed expressions (constants, calls, ...) have no + // variable-callback hazard, so read them normally. + return $native ? $this->getNativeType($expr) : $this->getType($expr); + } + public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { if ($expr instanceof Scalar) { @@ -3030,9 +3101,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getType($expr->dim)->toArrayKey(); + $dimType = $scope->getScopeStateType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getScopeStateType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -3056,7 +3127,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, $scope = $scope->specifyExpressionType( $expr->var, $varType, - $scope->getNativeType($expr->var), + $scope->getScopeStateNativeType($expr->var), $certainty, ); } @@ -3294,12 +3365,12 @@ private function isComplexUnionType(Type $type): bool public function addTypeToExpression(Expr $expr, Type $type): self { - $originalExprType = $this->getType($expr); + $originalExprType = $this->getScopeStateType($expr); if ($this->isComplexUnionType($originalExprType)) { return $this; } - $nativeType = $this->getNativeType($expr); + $nativeType = $this->getScopeStateNativeType($expr); if ($originalExprType->equals($nativeType)) { $newType = TypeCombinator::intersect($type, $originalExprType); @@ -3320,7 +3391,7 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self return $this; } - $exprType = $this->getType($expr); + $exprType = $this->getScopeStateType($expr); if ($exprType instanceof NeverType) { return $this; } @@ -3332,7 +3403,7 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self return $this->specifyExpressionType( $expr, TypeCombinator::remove($exprType, $typeToRemove), - TypeCombinator::remove($this->getNativeType($expr), $typeToRemove), + TypeCombinator::remove($this->getScopeStateNativeType($expr), $typeToRemove), TrinaryLogic::createYes(), ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c123e8ee747..765766ad770 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3106,7 +3106,7 @@ private function processExprNodeInternal( impurePoints: $newExprResult->getImpurePoints(), // the first-class callable closure type lives on the *CallableNode // result; delegate so getType() of the original CallLike answers from it - typeCallback: static fn (MutatingScope $s): Type => $newExprResult->getTypeForScope($s), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $newExprResult->getNativeType() : $newExprResult->getType()), specifyTypesCallback: static fn () => new SpecifiedTypes(), ); $this->storeExpressionResult($storage, $expr, $expressionResult); From cacc4e3dea28843d5d68cc127e5ce3fda60e3e4c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 21:10:14 +0200 Subject: [PATCH 214/378] Require an ExpressionResult to have either a precomputed type or a typeCallback A result with neither cannot answer its own type - getType()/getNativeType() would fall through to beforeScope->getType()/getNativeType() with nothing backing them. No construction site does this (closures/arrow functions set type+nativeType eagerly, everything else sets a typeCallback), so guard the invariant in the constructor next to the existing mutual-exclusion check. --- src/Analyser/ExpressionResult.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 62fc49637a7..23c852d07fd 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -65,11 +65,15 @@ public function __construct( private ?Type $nativeType = null, ) { - // A precomputed type and a lazy typeCallback are mutually exclusive; phpdoc - // and native types are precomputed together or not at all. + // A precomputed type and a lazy typeCallback are mutually exclusive, but + // exactly one of them must be set - a result with neither cannot answer its + // own type. phpdoc and native types are precomputed together or not at all. if ($typeCallback !== null && $type !== null) { throw new ShouldNotHappenException('ExpressionResult cannot have both a typeCallback and a precomputed type.'); } + if ($typeCallback === null && $type === null) { + throw new ShouldNotHappenException('ExpressionResult must have either a precomputed type or a typeCallback.'); + } if (($type === null) !== ($nativeType === null)) { throw new ShouldNotHappenException('ExpressionResult type and nativeType must both be set or both be null.'); } From 7d6aad04e5d7d1dc59478771a2bcddfc03a61da4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 21:25:25 +0200 Subject: [PATCH 215/378] Read the tracked holder directly in ExpressionResult, not via Scope::getType On the tracked-holder path (typeCallback set but a narrowed holder for the whole expression wins), getType()/getNativeType()/getTypeForScope()/getNativeTypeForScope() fell back to MutatingScope::getType()/getNativeType(), re-entering the guard, the resolvedTypes cache and the new-world dispatch only to land back on the holder. Add MutatingScope::getTrackedExpressionType() - the same holder read resolveType() does for its tracked early return - and call it directly. The constructor guard guarantees a typeCallback is set when type is null, so this branch is only ever the tracked-holder case and the holder is known to exist. --- src/Analyser/ExpressionResult.php | 13 +++++++++---- src/Analyser/MutatingScope.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 23c852d07fd..6bdb6cb20a3 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -205,7 +205,10 @@ public function getType(): Type return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); } - return $this->cachedType = $this->beforeScope->getType($this->expr); + // The guard above leaves only one way here: the expression is tracked on + // beforeScope (typeCallback is set but a holder wins). Read the holder + // directly instead of re-entering MutatingScope::getType(). + return $this->cachedType = $this->beforeScope->getTrackedExpressionType($this->expr); } public function getNativeType(): Type @@ -222,7 +225,9 @@ public function getNativeType(): Type return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); } - return $this->cachedNativeType = $this->beforeScope->getNativeType($this->expr); + // Tracked native holder (getNativeType() promotes the scope, so its + // expressionTypes are the native ones) - read it directly. + return $this->cachedNativeType = $this->beforeScope->doNotTreatPhpDocTypesAsCertain()->getTrackedExpressionType($this->expr); } /** @@ -296,7 +301,7 @@ public function getTypeForScope(MutatingScope $scope): Type return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->nativeTypesPromoted)); } - return $scope->getType($this->expr); + return $scope->getTrackedExpressionType($this->expr); } /** Native counterpart of getTypeForScope(). */ @@ -311,7 +316,7 @@ public function getNativeTypeForScope(MutatingScope $scope): Type return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); } - return $scope->getNativeType($this->expr); + return $nativeScope->getTrackedExpressionType($this->expr); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 491c40b032e..69ed829bb90 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1447,6 +1447,19 @@ public function hasExpressionType(Expr $node): TrinaryLogic return ScopeOps::hasExpressionType($this, $node, $this->exprPrinter); } + /** + * Reads the type tracked for an expression straight from its holder, skipping + * the extension/dispatch/cache machinery that getType() runs. Only valid when + * hasExpressionType($node) is yes - mirrors resolveType()'s tracked-holder + * early return and is what ExpressionResult uses on its tracked-holder path. + * + * @internal + */ + public function getTrackedExpressionType(Expr $node): Type + { + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + /** * @param MethodReflection|FunctionReflection|null $reflection */ From 4ac500f12dfc33165d0d63535a5272043530c0fd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 27 Jun 2026 23:08:21 +0200 Subject: [PATCH 216/378] Get rid of truthyScopeCallback and falseyScopeCallback --- src/Analyser/ExprHandler/AssignHandler.php | 4 +-- .../ExprHandler/BooleanAndHandler.php | 6 ++-- src/Analyser/ExprHandler/BooleanOrHandler.php | 28 ++++++++++----- .../ConditionalExpressionHolderHelper.php | 14 ++------ src/Analyser/ExpressionResult.php | 34 ++++--------------- src/Analyser/ExpressionResultFactory.php | 4 --- 6 files changed, 31 insertions(+), 59 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index b996245638c..23de3185886 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -481,8 +481,8 @@ public function processAssignVar( $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); + $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); $truthyType = $nodeScopeResolver->readStoredOrPriceOnDemand($if, $truthyScope); $falseyType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr->else, $falsyScope); diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index a20cfeee076..50775774d30 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -95,8 +95,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), - falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isFalse()->yes()) { @@ -123,7 +121,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); - $rightScope = $s->filterByTruthyValue($expr->left); + $rightScope = $leftResult->getTruthyScope(); $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); @@ -131,7 +129,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getFalseyScope(), $rightResult->getFalseyScope(), $types); } if ($context->false()) { // Consequent (holder) narrowings projected by each holder: these must be diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 60e53fc37dd..ba086b232bc 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -66,11 +66,16 @@ public function supports(Expr $expr): bool * skipped: in the OR-truthy scope the arm that didn't narrow could still be * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function augmentBooleanOrTruthyWithConditionalHolders(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes + private function augmentBooleanOrTruthyWithConditionalHolders( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $scope, + MutatingScope $leftTruthyScope, + MutatingScope $rightScope, + MutatingScope $rightTruthyScope, + BooleanOr|LogicalOr $expr, + SpecifiedTypes $types, + ): SpecifiedTypes { - $leftTruthyScope = $scope->filterByTruthyValue($expr->left); - $rightTruthyScope = $rightScope->filterByTruthyValue($expr->right); - $seen = []; foreach ([$scope, $rightScope] as $sourceScope) { foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) { @@ -145,8 +150,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isTrue()->yes()) { @@ -173,7 +176,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); - $rightScope = $s->filterByFalseyValue($expr->left); + $rightScope = $leftResult->getFalseyScope(); $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); if ($context->true()) { @@ -190,8 +193,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->augmentBooleanOrTruthyWithConditionalHolders($nodeScopeResolver, $s, $rightScope, $expr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders( + $nodeScopeResolver, + $s, + $leftResult->getTruthyScope(), + $rightScope, + $rightResult->getTruthyScope(), + $expr, + $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getTruthyScope(), $rightResult->getTruthyScope(), $types); } } else { $types = $leftTypes->unionWith($rightTypes); diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index a865fe7048c..aee0397e484 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -38,12 +38,10 @@ public function __construct( public function augmentDisjunctionTypes( NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, - MutatingScope $rightScope, SpecifiedTypes $leftNormalized, SpecifiedTypes $rightNormalized, - Expr $leftExpr, - Expr $rightExpr, - bool $truthy, + MutatingScope $leftFilteredScope, + MutatingScope $rightFilteredScope, SpecifiedTypes $types, ): SpecifiedTypes { @@ -72,14 +70,6 @@ public function augmentDisjunctionTypes( return $types; } - if ($truthy) { - $leftFilteredScope = $scope->filterByTruthyValue($leftExpr); - $rightFilteredScope = $rightScope->filterByTruthyValue($rightExpr); - } else { - $leftFilteredScope = $scope->filterByFalseyValue($leftExpr); - $rightFilteredScope = $rightScope->filterByFalseyValue($rightExpr); - } - foreach ($viableCandidates as $targetExpr) { if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) { continue; diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 6bdb6cb20a3..5f5bbeb70a0 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -22,14 +22,8 @@ final class ExpressionResult /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ private $createTypesCallback; - /** @var (callable(): MutatingScope)|null */ - private $truthyScopeCallback; - private ?MutatingScope $truthyScope = null; - /** @var (callable(): MutatingScope)|null */ - private $falseyScopeCallback; - private ?MutatingScope $falseyScope = null; private ?Type $cachedType = null; @@ -42,8 +36,6 @@ final class ExpressionResult * @param (callable(bool): Type)|null $typeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback - * @param (callable(): MutatingScope)|null $truthyScopeCallback - * @param (callable(): MutatingScope)|null $falseyScopeCallback */ public function __construct( private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, @@ -58,8 +50,6 @@ public function __construct( callable $specifyTypesCallback, private bool $containsNullsafe = false, private ?IssetabilityDescriptor $issetabilityDescriptor = null, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, ?callable $createTypesCallback = null, private ?Type $type = null, private ?Type $nativeType = null, @@ -78,8 +68,6 @@ public function __construct( throw new ShouldNotHappenException('ExpressionResult type and nativeType must both be set or both be null.'); } - $this->truthyScopeCallback = $truthyScopeCallback; - $this->falseyScopeCallback = $falseyScopeCallback; $this->typeCallback = $typeCallback; $this->specifyTypesCallback = $specifyTypesCallback; $this->createTypesCallback = $createTypesCallback; @@ -153,14 +141,9 @@ public function getTruthyScope(): MutatingScope return $this->truthyScope; } - if ($this->truthyScopeCallback === null) { - return $this->truthyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), - ); - } - - $callback = $this->truthyScopeCallback; - return $this->truthyScope = $callback(); + return $this->truthyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), + ); } public function getFalseyScope(): MutatingScope @@ -169,14 +152,9 @@ public function getFalseyScope(): MutatingScope return $this->falseyScope; } - if ($this->falseyScopeCallback === null) { - return $this->falseyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), - ); - } - - $callback = $this->falseyScopeCallback; - return $this->falseyScope = $callback(); + return $this->falseyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), + ); } public function isAlwaysTerminating(): bool diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 00bca2a32f5..147da44febf 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -11,8 +11,6 @@ interface ExpressionResultFactory /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints - * @param (callable(): MutatingScope)|null $truthyScopeCallback - * @param (callable(): MutatingScope)|null $falseyScopeCallback * @param (callable(bool): Type)|null $typeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback @@ -29,8 +27,6 @@ public function create( callable $specifyTypesCallback, bool $containsNullsafe = false, ?IssetabilityDescriptor $issetabilityDescriptor = null, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, ?callable $createTypesCallback = null, ?Type $type = null, ?Type $nativeType = null, From ce9d54bd8b30d20c747ab3d38ab465c8f3c1b9fe Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 28 Jun 2026 00:28:47 +0200 Subject: [PATCH 217/378] Use the operand's own truthy/falsey scope for &&/|| narrowing getTruthyScope()/getFalseyScope() applied the specifyTypesCallback narrowing to the result's own scope, which for &&/|| is the merge of the two operand scopes. That was wrong in two ways: - the merge demotes a by-ref/side-effect definition made in the right operand (e.g. $m from $this->match(..., $m) on the right of an &&) to maybe-defined, losing it; and - applying the whole &&/|| narrowing re-applies the LEFT operand's narrowing on top of a scope where the right operand reassigned the narrowed variable - e.g. ctype_digit($foo) re-applied to the int $foo after $foo = intval($foo) in `!ctype_digit($foo) || ($foo = intval($foo)) < 1`, giving int<48,57>|int<256,max> instead of int<1, max> (bug-9400). && is truthy (|| is falsey) only when the right operand was evaluated - on the left-truthy (left-falsey) scope - and is itself truthy (falsey). That is exactly $rightResult->getTruthyScope() ($rightResult->getFalseyScope()): it already carries the left operand's narrowing (baked in by processing the right side on the left-narrowed scope) and the right's by-ref definitions, and does not re-narrow a reassigned variable. Pass it as truthyScopeOverride/falseyScopeOverride on ExpressionResult; only BooleanAnd and BooleanOr need it, as the only handlers that merge operand scopes. The narrowing exposed to parents still comes from specifyTypesCallback, so dropping its scope parameter later is unaffected. --- .../ExprHandler/BooleanAndHandler.php | 6 ++++ src/Analyser/ExprHandler/BooleanOrHandler.php | 6 ++++ src/Analyser/ExpressionResult.php | 18 +++++++++++ src/Analyser/ExpressionResultFactory.php | 2 ++ ...ditional-expr-narrowing-second-operand.php | 31 +++++++++++++++++++ 5 files changed, 63 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 50775774d30..218bc381b8b 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -95,6 +95,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + // && is truthy only when the right side was evaluated (on the left-truthy + // scope) and is itself truthy - that is exactly the right operand's truthy + // scope: it carries the left narrowing and the right's by-ref/side-effect + // definitions, and does not re-apply the left narrowing over a variable the + // right operand reassigned (bug-9400). + truthyScopeOverride: $rightResult->getTruthyScope(), typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isFalse()->yes()) { diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index ba086b232bc..198962f1929 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -150,6 +150,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + // || is falsey only when the right side was evaluated (on the left-falsey + // scope) and is itself falsey - that is exactly the right operand's falsey + // scope: it carries the left narrowing and the right's by-ref/side-effect + // definitions, and does not re-apply the left narrowing over a variable the + // right operand reassigned (bug-9400). + falseyScopeOverride: $rightResult->getFalseyScope(), typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); if ($leftBooleanType->isTrue()->yes()) { diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 5f5bbeb70a0..d5be16d74e8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -50,6 +50,8 @@ public function __construct( callable $specifyTypesCallback, private bool $containsNullsafe = false, private ?IssetabilityDescriptor $issetabilityDescriptor = null, + private ?MutatingScope $truthyScopeOverride = null, + private ?MutatingScope $falseyScopeOverride = null, ?callable $createTypesCallback = null, private ?Type $type = null, private ?Type $nativeType = null, @@ -141,6 +143,16 @@ public function getTruthyScope(): MutatingScope return $this->truthyScope; } + // && is truthy only when the right operand was evaluated (on the left-truthy + // scope) and is itself truthy - that is exactly $rightResult->getTruthyScope(), + // which the handler passes as $truthyScopeOverride. It already carries the left + // operand's narrowing and the right operand's by-ref/side-effect definitions, + // and crucially does NOT re-apply the left narrowing on top of a scope where the + // right operand reassigned the narrowed variable (see bug-9400). + if ($this->truthyScopeOverride !== null) { + return $this->truthyScope = $this->truthyScopeOverride; + } + return $this->truthyScope = $this->scope->applySpecifiedTypes( ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), ); @@ -152,6 +164,12 @@ public function getFalseyScope(): MutatingScope return $this->falseyScope; } + // || is falsey only when the right operand was evaluated (on the left-falsey + // scope) and is itself falsey - that is exactly $rightResult->getFalseyScope(). + if ($this->falseyScopeOverride !== null) { + return $this->falseyScope = $this->falseyScopeOverride; + } + return $this->falseyScope = $this->scope->applySpecifiedTypes( ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), ); diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 147da44febf..9c8af7d3f01 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -27,6 +27,8 @@ public function create( callable $specifyTypesCallback, bool $containsNullsafe = false, ?IssetabilityDescriptor $issetabilityDescriptor = null, + ?MutatingScope $truthyScopeOverride = null, + ?MutatingScope $falseyScopeOverride = null, ?callable $createTypesCallback = null, ?Type $type = null, ?Type $nativeType = null, diff --git a/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php b/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php new file mode 100644 index 00000000000..f46fe58db1d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php @@ -0,0 +1,31 @@ +unsealed !== null && $b->unsealed !== null; + + // $bothDefinite as the first && operand + if ($bothDefinite && $other) { + assertType('array{int, int}', $a->unsealed); + assertType('array{int, int}', $b->unsealed); + } + + // $bothDefinite as the second && operand - regressed to array{int, int}|null + // because filterBySpecifiedTypes read the un-narrowed $bothDefinite via getType() + if ($other && $bothDefinite) { + assertType('array{int, int}', $a->unsealed); + assertType('array{int, int}', $b->unsealed); + } +} From fa6df9e3fe813ffec2dfc289ec2455cb928f4922 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 28 Jun 2026 12:10:16 +0200 Subject: [PATCH 218/378] Record conditional holders from by-ref-updated variables via scope state A `$result = preg_match($p, $s, $matches)` assignment records a conditional holder "$result truthy -> $matches = ". Building it intersected the narrowed shape with $matches's current type, read via readStoredOrPriceOnDemand(). But preg_match writes $matches by ref: the write lands in the scope's tracked variable type ($matches becomes array{}|array{matched}), while the stored ExpressionResult from the earlier `$matches = []` is left untouched. readStoredOrPriceOnDemand() returned that stale array{}, so the holder recorded array{} & matched = NEVER, and `if ($result)` narrowed $matches to *NEVER*. Read the holder expression's current type from the scope state for tracked variables - getVariableType(), which is null-safe for superglobals and undefined variables - where a by-ref write may have updated the type. Non-variable holder exprs (method calls etc.) have no by-ref hazard and keep reading their stored result. --- src/Analyser/ExprHandler/AssignHandler.php | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 23de3185886..4ac7ad2892e 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1168,7 +1168,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(NodeScopeR $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $nodeScopeResolver->readStoredOrPriceOnDemand($innerExpr, $scope), + $this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $innerExpr), TrinaryLogic::createMaybe(), ); continue; @@ -1182,7 +1182,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(NodeScopeR $variableType, $expr, $exprString, - TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $exprType), + TypeCombinator::intersect($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr), $exprType), TrinaryLogic::createYes(), ); } @@ -1224,7 +1224,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeSco $variableType, $expr, $exprString, - TypeCombinator::remove($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $exprType), + TypeCombinator::remove($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr), $exprType), TrinaryLogic::createYes(), ); } @@ -1232,6 +1232,27 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeSco return $conditionalExpressions; } + /** + * Current type of a conditional-holder expression, used to refine the holder's + * projected type. Prefers the tracked scope state over readStoredOrPriceOnDemand(), + * whose stored ExpressionResult can be stale after a by-ref write - e.g. + * preg_match($p, $s, $matches) updates $matches in the scope state but leaves the + * stored result from the earlier `$matches = []` untouched, so reading it back would + * intersect the matched shape against the stale array{} and collapse to NEVER. + */ + private function currentTypeForConditionalHolder(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr): Type + { + // A by-ref write lands in the variable's tracked type, so read it from the + // scope state (getVariableType is null-safe for superglobals/undefined too). + // Method calls and other non-variable holder exprs have no by-ref hazard and + // keep reading their stored result. + if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->yes()) { + return $scope->getVariableType($expr->name); + } + + return $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + } + /** * @param array $conditionalExpressions * @return array From 103d013c9cf8baa7307421bdc197717243e83e25 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 28 Jun 2026 15:20:01 +0200 Subject: [PATCH 219/378] Derive statement exit points from never instead of findEarlyTerminatingExpr NodeScopeResolver::findEarlyTerminatingExpr() reached Scope::getType() to recognise never-returning expression statements, violating the single-pass invariant (the engine must consume ExpressionResults, not Scope::getType()). Move the early-terminating method/function recognition into the call handlers via a shared EarlyTerminatingCallHelper: a MethodCall/StaticCall/FuncCall configured as early-terminating now resolves to an explicit NeverType. The expression statement's exit point then follows from the result's value type being an explicit never - which already covers exit/die/throw and signature-never calls - so findEarlyTerminatingExpr and its getType() call are gone. The earlyTerminatingMethodCalls/earlyTerminatingFunctionCalls lists move off NodeScopeResolver's constructor onto the helper as DI parameters; the test-case overrides are replaced by a nodeScopeResolverEarlyTerminating.neon parameter file. --- src/Analyser/ExprHandler/FuncCallHandler.php | 29 ++++--- .../Helper/EarlyTerminatingCallHelper.php | 80 +++++++++++++++++++ .../ExprHandler/MethodCallHandler.php | 28 ++++--- .../ExprHandler/StaticCallHandler.php | 34 +++++--- src/Analyser/NodeScopeResolver.php | 77 ++---------------- src/Testing/RuleTestCase.php | 2 - src/Testing/TypeInferenceTestCase.php | 14 ---- tests/PHPStan/Analyser/AnalyserTest.php | 2 - .../Fiber/FiberNodeScopeResolverRuleTest.php | 2 - .../Fiber/FiberNodeScopeResolverTest.php | 2 - .../Analyser/LegacyNodeScopeResolverTest.php | 16 +--- .../Analyser/NodeScopeResolverTest.php | 16 +--- .../nodeScopeResolverEarlyTerminating.neon | 7 ++ 13 files changed, 157 insertions(+), 152 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php create mode 100644 tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 0ab87271c9c..1c84ef4f741 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -21,6 +21,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -102,6 +103,7 @@ public function __construct( private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -121,7 +123,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = null; $throwPoints = []; $impurePoints = []; - $isAlwaysTerminating = false; + // A call configured as early-terminating never returns: give it an explicit + // never so the statement's exit point follows from the result type, instead of + // NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = $expr->name instanceof Name + && $this->earlyTerminatingHelper->isEarlyTerminatingFunctionCall($expr->name->toString()); + $isAlwaysTerminating = $isEarlyTerminating; if ($expr->name instanceof Expr) { // process the dynamic callee name first, then consume its type (single-pass // inside-out) rather than reading it before processExprNode() stores it @@ -322,15 +329,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // (native-types-promoted, on-demand / synthetic pricing, or special cases // inside resolveReturnType), the acceptor is re-derived from the // already-processed argument results on the asking scope. - $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( - $nodeScopeResolver, - $beforeScope, - $nativeTypesPromoted, - $expr, - $nameResult, - $nativeTypesPromoted ? null : $resolvedParametersAcceptor, - $argsResult, - ); + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $nodeScopeResolver, + $beforeScope, + $nativeTypesPromoted, + $expr, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, + ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, $s, diff --git a/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php new file mode 100644 index 00000000000..d44df0587f3 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php @@ -0,0 +1,80 @@ + */ + private array $earlyTerminatingMethodNames; + + /** + * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) + * @param array $earlyTerminatingFunctionCalls + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter] + private array $earlyTerminatingMethodCalls, + #[AutowiredParameter] + private array $earlyTerminatingFunctionCalls, + ) + { + $earlyTerminatingMethodNames = []; + foreach ($this->earlyTerminatingMethodCalls as $methodNames) { + foreach ($methodNames as $methodName) { + $earlyTerminatingMethodNames[strtolower($methodName)] = true; + } + } + $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + } + + public function isEarlyTerminatingMethodCall(string $methodName, Type $calledOnType): bool + { + if (!array_key_exists(strtolower($methodName), $this->earlyTerminatingMethodNames)) { + return false; + } + + foreach ($calledOnType->getObjectClassNames() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($referencedClass); + foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { + if (!isset($this->earlyTerminatingMethodCalls[$className])) { + continue; + } + + if (in_array($methodName, $this->earlyTerminatingMethodCalls[$className], true)) { + return true; + } + } + } + + return false; + } + + public function isEarlyTerminatingFunctionCall(string $functionName): bool + { + return in_array($functionName, $this->earlyTerminatingFunctionCalls, true); + } + +} diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index e3bb5b761b4..dc3431f85c9 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ImpurePoint; @@ -65,6 +66,7 @@ public function __construct( private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -112,6 +114,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the var was processed above as the receiver; read its already-computed // result instead of re-walking via Scope::getType(). $calledOnType = $varResult->getTypeForScope($scope); + // A call configured as early-terminating never returns: give it an explicit + // never so the statement's exit point follows from the result type, instead of + // NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = $expr->name instanceof Identifier + && $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $calledOnType); + $isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating; if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); @@ -172,15 +180,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // evolving scope (type-driven, generics resolved). When null // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is // re-derived from the already-processed argument results on the asking scope. - $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( - $nodeScopeResolver, - $beforeScope, - $nativeTypesPromoted, - $expr, - $varResult, - $nameResult, - $nativeTypesPromoted ? null : $resolvedParametersAcceptor, - ); + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $nodeScopeResolver, + $beforeScope, + $nativeTypesPromoted, + $expr, + $varResult, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, $s, diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 7fd3ea24280..a8cb5bd2414 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ImpurePoint; @@ -69,6 +70,7 @@ public function __construct( private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -99,6 +101,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $containsNullsafe = $classResult->containsNullsafe(); } + // A static call configured as early-terminating never returns: give it an + // explicit never so the statement's exit point follows from the result type, + // instead of NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = false; + if ($expr->name instanceof Identifier) { + $earlyTerminatingClassType = $expr->class instanceof Name + ? $scope->resolveTypeByName($expr->class) + : $classResult->getTypeForScope($scope); + $isEarlyTerminating = $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $earlyTerminatingClassType); + } + $isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating; + $parametersAcceptor = null; $variants = []; $namedArgumentsVariants = null; @@ -243,15 +257,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // evolving scope (type-driven, generics resolved). When null // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is // re-derived from the already-processed argument results on the asking scope. - $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( - $nodeScopeResolver, - $beforeScope, - $nativeTypesPromoted, - $expr, - $classResult, - $nameResult, - $nativeTypesPromoted ? null : $resolvedParametersAcceptor, - ); + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $nodeScopeResolver, + $beforeScope, + $nativeTypesPromoted, + $expr, + $classResult, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, $s, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 765766ad770..314a9ef5589 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -204,9 +204,6 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; - /** @var array */ - private array $earlyTerminatingMethodNames; - /** @var array */ private array $calledMethodStack = []; @@ -251,10 +248,6 @@ class NodeScopeResolver */ public static array $guardProcessedExprIds = []; - /** - * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) - * @param array $earlyTerminatingFunctionCalls - */ public function __construct( private readonly Container $container, private readonly ReflectionProvider $reflectionProvider, @@ -279,10 +272,6 @@ public function __construct( private readonly bool $polluteScopeWithAlwaysIterableForeach, #[AutowiredParameter] private readonly bool $polluteScopeWithBlock, - #[AutowiredParameter] - private readonly array $earlyTerminatingMethodCalls, - #[AutowiredParameter] - private readonly array $earlyTerminatingFunctionCalls, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private readonly bool $implicitThrows, #[AutowiredParameter] @@ -291,14 +280,6 @@ public function __construct( private readonly ExpressionResultFactory $expressionResultFactory, ) { - $earlyTerminatingMethodNames = []; - foreach ($this->earlyTerminatingMethodCalls as $methodNames) { - foreach ($methodNames as $methodName) { - $earlyTerminatingMethodNames[strtolower($methodName)] = true; - } - } - $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; - self::$guardNewWorld = getenv('PHPSTAN_GUARD_NW') === '1'; } @@ -1253,7 +1234,6 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -1274,7 +1254,11 @@ public function processStmtNode( $impurePoints = $result->getImpurePoints(); $isAlwaysTerminating = $result->isAlwaysTerminating(); - if ($earlyTerminationExpr !== null) { + // The expression statement is an exit point when its value type is an + // explicit never: exit/die/throw, a never-returning call, or a call + // configured as early-terminating (the call handlers give those never). + $statementType = $result->getType(); + if ($statementType instanceof NeverType && $statementType->isExplicit()) { return new InternalStatementResult($scope, $hasYield, true, [ new InternalStatementExitPoint($stmt, $scope), ], $overridingThrowPoints ?? $throwPoints, $impurePoints); @@ -2875,57 +2859,6 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr - { - if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { - if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { - if ($expr instanceof MethodCall) { - $methodCalledOnType = $this->readStoredOrPriceOnDemand($expr->var, $scope->toMutatingScope()); - } else { - if ($expr->class instanceof Name) { - $methodCalledOnType = $scope->resolveTypeByName($expr->class); - } else { - $methodCalledOnType = $this->readStoredOrPriceOnDemand($expr->class, $scope->toMutatingScope()); - } - } - - foreach ($methodCalledOnType->getObjectClassNames() as $referencedClass) { - if (!$this->reflectionProvider->hasClass($referencedClass)) { - continue; - } - - $classReflection = $this->reflectionProvider->getClass($referencedClass); - foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { - if (!isset($this->earlyTerminatingMethodCalls[$className])) { - continue; - } - - if (in_array((string) $expr->name, $this->earlyTerminatingMethodCalls[$className], true)) { - return $expr; - } - } - } - } - } - - if ($expr instanceof FuncCall && $expr->name instanceof Name) { - if (in_array((string) $expr->name, $this->earlyTerminatingFunctionCalls, true)) { - return $expr; - } - } - - if ($expr instanceof Expr\Exit_ || $expr instanceof Expr\Throw_) { - return $expr; - } - - $exprType = $scope->getType($expr); - if ($exprType instanceof NeverType && $exprType->isExplicit()) { - return $expr; - } - - return null; - } - /** * Processes an expression outside the normal AST traversal - e.g. a synthetic * node a rule or extension asks about. Real AST nodes contained in it return diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 0a67df7cd7a..230edd81202 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -115,8 +115,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 3cda273ecf0..774357f5227 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -89,8 +89,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), @@ -488,16 +486,4 @@ protected static function getAdditionalAnalysedFiles(): array return []; } - /** @return string[][] */ - protected static function getEarlyTerminatingMethodCalls(): array - { - return []; - } - - /** @return string[] */ - protected static function getEarlyTerminatingFunctionCalls(): array - { - return []; - } - } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index b666e98cf31..cb2fae8336b 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -830,8 +830,6 @@ private function createAnalyser(): Analyser false, true, true, - [], - [], true, $this->shouldTreatPhpDocTypesAsCertain(), $container->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index fae07c69bb5..7dca44196ee 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -134,8 +134,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 608bfee99ec..694c3a5e233 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -67,8 +67,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index fc474ac68db..cdd9a94467f 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -46,6 +46,7 @@ public static function getAdditionalConfigFiles(): array return [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ]; } @@ -92,19 +93,4 @@ public function testEarlyTermination(): void }); } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index ed2e6f788ac..24f55aad643 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -355,6 +355,7 @@ public static function getAdditionalConfigFiles(): array [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ], ); } @@ -369,19 +370,4 @@ protected static function getAdditionalAnalysedFiles(): array ]; } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon new file mode 100644 index 00000000000..90badb85150 --- /dev/null +++ b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon @@ -0,0 +1,7 @@ +parameters: + earlyTerminatingMethodCalls: + EarlyTermination\Foo: + - doFoo + - doBar + earlyTerminatingFunctionCalls: + - baz From c638a4db113eaef05500c69d46ba1c9536b44a48 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 29 Jun 2026 22:08:09 +0200 Subject: [PATCH 220/378] Narrower return type --- .../ExprHandler/Helper/DefaultNarrowingHelper.php | 3 +-- src/Analyser/MutatingScope.php | 2 +- src/Analyser/NodeScopeResolver.php | 5 +---- src/Analyser/TypeSpecifier.php | 5 +---- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 6 +----- src/Testing/RuleTestCase.php | 1 - src/Testing/TypeInferenceTestCase.php | 1 - ...eSpecifyingFunctionsDynamicReturnTypeExtension.php | 11 ++--------- tests/PHPStan/Analyser/AnalyserTest.php | 1 - .../Analyser/Fiber/FiberNodeScopeResolverRuleTest.php | 1 - .../Analyser/Fiber/FiberNodeScopeResolverTest.php | 1 - .../BooleanAndConstantConditionRuleTest.php | 3 --- .../BooleanNotConstantConditionRuleTest.php | 3 --- .../Comparison/BooleanOrConstantConditionRuleTest.php | 3 --- .../DoWhileLoopConstantConditionRuleTest.php | 3 --- .../Comparison/ElseIfConstantConditionRuleTest.php | 3 --- .../Rules/Comparison/IfConstantConditionRuleTest.php | 3 --- .../ImpossibleCheckTypeFunctionCallRuleTest.php | 1 - .../ImpossibleCheckTypeGenericOverwriteRuleTest.php | 1 - .../ImpossibleCheckTypeMethodCallRuleEqualsTest.php | 1 - .../ImpossibleCheckTypeMethodCallRuleTest.php | 1 - .../ImpossibleCheckTypeStaticMethodCallRuleTest.php | 1 - .../LogicalXorConstantConditionRuleTest.php | 3 --- .../Rules/Comparison/MatchExpressionRuleTest.php | 3 --- .../TernaryOperatorConstantConditionRuleTest.php | 3 --- .../WhileLoopAlwaysFalseConditionRuleTest.php | 3 --- .../WhileLoopAlwaysTrueConditionRuleTest.php | 3 --- 27 files changed, 7 insertions(+), 68 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index e8f4ae7cd23..4451ac88e56 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -65,8 +65,7 @@ public function specifyTypesForNode(Scope $scope, Expr $node, TypeSpecifierConte return (new SpecifiedTypes([], []))->setRootExpr($node); } - return $scope->toMutatingScope()->specifyTypesOfNewWorldHandlerNode($node, $context) - ?? $this->specifyDefaultTypes($node, $context); + return $scope->toMutatingScope()->specifyTypesOfNewWorldHandlerNode($node, $context); } public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 69ed829bb90..6c24bdca743 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1240,7 +1240,7 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array * * @internal */ - public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): ?SpecifiedTypes + public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): SpecifiedTypes { // see resolveTypeOfNewWorldHandlerNode() - rules ask the dispatcher // with their FiberScope (e.g. ImpossibleCheckTypeHelper), the engine diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 314a9ef5589..33a7d252c76 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6,7 +6,6 @@ use Closure; use IteratorAggregate; use Override; -use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Comment\Doc; use PhpParser\Modifiers; use PhpParser\Node; @@ -260,7 +259,6 @@ public function __construct( private readonly FileTypeMapper $fileTypeMapper, private readonly PhpDocInheritanceResolver $phpDocInheritanceResolver, private readonly FileHelper $fileHelper, - private readonly TypeSpecifier $typeSpecifier, private readonly ReadWritePropertiesExtensionProvider $readWritePropertiesExtensionProvider, private readonly ParameterClosureThisExtensionProvider $parameterClosureThisExtensionProvider, private readonly ParameterClosureTypeExtensionProvider $parameterClosureTypeExtensionProvider, @@ -433,8 +431,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void */ private function narrowScopeWithCondition(MutatingScope $scope, Expr $expr, TypeSpecifierContext $context): MutatingScope { - $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context) - ?? $this->typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); return $scope->applySpecifiedTypes($specifiedTypes); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 6c7b179b186..9b4d0982678 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -91,10 +91,7 @@ public function specifyTypesInCondition( $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { if ($scope instanceof MutatingScope) { - $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); - if ($specifiedTypes !== null) { - return $specifiedTypes; - } + return $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); } } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 40e04f0d7ba..ebcbe916aeb 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -11,7 +11,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\Scope; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -49,7 +48,6 @@ final class ImpossibleCheckTypeHelper public function __construct( private ReflectionProvider $reflectionProvider, - private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -355,8 +353,7 @@ private function getSpecifiedType( // instead of asking the scope to specify it before the call is processed. $specifiedTypes = ($nodeResult !== null ? $nodeResult->getSpecifiedTypesForScope($typeSpecifierScope, $typeSpecifierContext) - : $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext)) - ?? $this->typeSpecifier->specifyDefaultTypes($typeSpecifierScope, $node, $typeSpecifierContext); + : $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext)); // don't validate types on overwrite if ($specifiedTypes->shouldOverwrite()) { @@ -546,7 +543,6 @@ public function doNotTreatPhpDocTypesAsCertain(): self return new self( $this->reflectionProvider, - $this->typeSpecifier, false, ); } diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 230edd81202..5155b8496e1 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -106,7 +106,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureThisExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 774357f5227..75fe0b31384 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -80,7 +80,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), diff --git a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php index 804f3d5ac27..7df7c262388 100644 --- a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -18,11 +18,9 @@ use function in_array; #[AutowiredService] -final class TypeSpecifyingFunctionsDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension, TypeSpecifierAwareExtension +final class TypeSpecifyingFunctionsDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - private TypeSpecifier $typeSpecifier; - private ?ImpossibleCheckTypeHelper $helper = null; public function __construct( @@ -33,11 +31,6 @@ public function __construct( { } - public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void - { - $this->typeSpecifier = $typeSpecifier; - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), [ @@ -71,7 +64,7 @@ public function getTypeFromFunctionCall( private function getHelper(): ImpossibleCheckTypeHelper { - return $this->helper ??= new ImpossibleCheckTypeHelper($this->reflectionProvider, $this->typeSpecifier, $this->treatPhpDocTypesAsCertain); + return $this->helper ??= new ImpossibleCheckTypeHelper($this->reflectionProvider, $this->treatPhpDocTypesAsCertain); } } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index cb2fae8336b..bd696ce7f7f 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -821,7 +821,6 @@ private function createAnalyser(): Analyser $fileTypeMapper, $phpDocInheritanceResolver, $fileHelper, - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 7dca44196ee..5f073066d2d 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -125,7 +125,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureThisExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 694c3a5e233..592e8e10ea9 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -58,7 +58,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index 9967649a4c8..81e2aea0a72 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index 5e5cbe633d2..438b542441f 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -35,7 +35,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -48,7 +47,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -61,7 +59,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index c32ef7f10e3..84fd1c59dc3 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index bde9382cef4..ce4efddafd7 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 766f6d1aa77..8f706ac679f 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 96b20b07bd1..72047361970 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -32,7 +32,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -45,7 +44,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -58,7 +56,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 4bbb387ecbb..7a74be1cde7 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -29,7 +29,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index b706d1569f8..9e7ed77f674 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -16,7 +16,6 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php index f2b07c46b6e..5252c264e67 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -16,7 +16,6 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 27c4bbac60a..4a11f703d65 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -25,7 +25,6 @@ public function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1a479ec9784..27fa1390e57 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -25,7 +25,6 @@ public function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index a64a549b5ba..834386423f5 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -31,7 +31,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -44,7 +43,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -57,7 +55,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 844d145d8a2..35a91c5a751 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -31,7 +31,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -44,7 +43,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -57,7 +55,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index dce53141a33..ba69d01a77f 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -32,7 +32,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -45,7 +44,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -58,7 +56,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index e05b9254a39..0ed691a60b4 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php index 4dc0cfba24e..23846895d88 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), From d3fe41262af3369fa25e04561624abab36715ab4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 29 Jun 2026 22:27:11 +0200 Subject: [PATCH 221/378] Do not use getChildSpecifiedTypes --- src/Analyser/ExprHandler/BooleanAndHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 218bc381b8b..00b5dbf9354 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -126,9 +126,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { - $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); + $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); $rightScope = $leftResult->getTruthyScope(); - $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); + $rightTypes = $rightResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); } else { From bc3bc99bc915643e06c4acecdb8429abfb5a0714 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 13:07:51 +0200 Subject: [PATCH 222/378] Inline getSpecifiedTypesForScope instead of getChildSpecifiedTypes Compose a child expression's narrowing directly from its ExpressionResult. Where the result is null or nullable (synthetic casts/ternary condition, the assigned expression, equality operands), obtain it first via the new MutatingScope::obtainResultForNode() - the stored result, or an on-demand walk against a duplicate of the current storage - then ask it. This is what specifyTypesOfNewWorldHandlerNode() already did internally; it now delegates to the same primitive. Drop the now-unused DefaultNarrowingHelper dependency from the three handlers that no longer narrow through it. --- src/Analyser/ExprHandler/AssignHandler.php | 4 +++- .../ExprHandler/BooleanAndHandler.php | 10 ++++----- .../ExprHandler/BooleanNotHandler.php | 2 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 4 ++-- src/Analyser/ExprHandler/CastHandler.php | 21 +++---------------- .../ExprHandler/CastStringHandler.php | 9 ++------ .../ExprHandler/ErrorSuppressHandler.php | 4 +--- .../Helper/DefaultNarrowingHelper.php | 17 --------------- .../Helper/EqualityTypeSpecifyingHelper.php | 20 +++++------------- src/Analyser/ExprHandler/TernaryHandler.php | 2 +- src/Analyser/MutatingScope.php | 20 +++++++++++++----- 11 files changed, 37 insertions(+), 76 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 4ac7ad2892e..f0e0a2a30b6 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -267,7 +267,9 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver { return function (MutatingScope $s, TypeSpecifierContext $context) use ($nodeScopeResolver, $expr, $assignedExprResult): SpecifiedTypes { if ($context->null()) { - $specifiedTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s->exitFirstLevelStatements(), $expr->expr, $assignedExprResult, $context)->setRootExpr($expr); + $assignedScope = $s->exitFirstLevelStatements(); + $result = $assignedExprResult ?? $assignedScope->obtainResultForNode($expr->expr); + $specifiedTypes = $result->getSpecifiedTypesForScope($assignedScope, $context)->setRootExpr($expr); $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); } else { $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 00b5dbf9354..822ac22297f 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -12,7 +12,6 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -36,7 +35,6 @@ final class BooleanAndHandler implements ExprHandler public function __construct( private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -146,10 +144,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. if ($context->truthy()) { if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { - $leftHolderTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, TypeSpecifierContext::createFalsey())->setRootExpr($expr); + $leftHolderTypes = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { - $rightHolderTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, TypeSpecifierContext::createFalsey())->setRootExpr($expr); + $rightHolderTypes = $rightResult->getSpecifiedTypesForScope($rightScope, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } } // Condition (antecedent) narrowings: when an arm has no falsey narrowing @@ -162,13 +160,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftCondTypes = $leftHolderTypes; $rightCondTypes = $rightHolderTypes; if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === []) { - $truthyLeftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, TypeSpecifierContext::createTruthy()); + $truthyLeftTypes = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy()); if ($this->allExpressionsTrackable($truthyLeftTypes)) { $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); } } if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { - $truthyRightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, TypeSpecifierContext::createTruthy()); + $truthyRightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, TypeSpecifierContext::createTruthy()); if ($this->allExpressionsTrackable($truthyRightTypes)) { $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 59f15900cf9..d4986986099 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -71,7 +71,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // The negated operand was processed above; compose its narrowing // directly from its result rather than re-resolving the node. - return $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context->negate())->setRootExpr($expr); + return $exprResult->getSpecifiedTypesForScope($s, $context->negate())->setRootExpr($expr); }, ); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 198962f1929..cb8e9f4c526 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -181,9 +181,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { - $leftTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->left, $leftResult, $context)->setRootExpr($expr); + $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); $rightScope = $leftResult->getFalseyScope(); - $rightTypes = $this->defaultNarrowingHelper->getChildSpecifiedTypes($rightScope, $expr->right, $rightResult, $context)->setRootExpr($expr); + $rightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, $context)->setRootExpr($expr); if ($context->true()) { if ( diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index a236cff2376..f472b261e83 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -76,30 +76,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { if ($expr instanceof Cast\Bool_) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( - $s, - new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))), - null, - $context, - )->setRootExpr($expr); + return $s->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); } if ($expr instanceof Cast\Int_) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( - $s, - new NotEqual($expr->expr, new Int_(0)), - null, - $context, - )->setRootExpr($expr); + return $s->obtainResultForNode(new NotEqual($expr->expr, new Int_(0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); } if ($expr instanceof Cast\Double) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( - $s, - new NotEqual($expr->expr, new Float_(0.0)), - null, - $context, - )->setRootExpr($expr); + return $s->obtainResultForNode(new NotEqual($expr->expr, new Float_(0.0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); } return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index c788cb04a45..102ae599a5f 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -12,7 +12,6 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -35,7 +34,6 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -73,12 +71,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes( - $s, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $s->obtainResultForNode( new NotEqual($expr->expr, new String_('')), - null, - $context, - )->setRootExpr($expr), + )->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index c314340c5b7..4399034b59e 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -10,7 +10,6 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -27,7 +26,6 @@ final class ErrorSuppressHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $expr->expr, $exprResult, $context)->setRootExpr($expr), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 4451ac88e56..24e6ef3bf27 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -35,23 +35,6 @@ public function __construct( { } - /** - * The narrowing of an already-processed child expression in the given - * boolean context: answered by the child result's specifyTypesCallback. - * When the child wired no callback, or is a synthetic node with no result, - * it is processed on demand and asked for its narrowing - the same path - * TypeSpecifier::specifyTypesInCondition() routes handler-supported nodes - * through, but without the old-world dispatcher. - */ - public function getChildSpecifiedTypes(MutatingScope $s, Expr $childExpr, ?ExpressionResult $childResult, TypeSpecifierContext $context): SpecifiedTypes - { - if ($childResult !== null) { - return $childResult->getSpecifiedTypesForScope($s, $context); - } - - return $this->specifyTypesForNode($s, $childExpr, $context); - } - /** * Narrows an arbitrary (often synthetic) node in the given boolean context by * processing it on demand and asking its result, the inside-out replacement diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 62aa862bcd6..99195644f45 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -96,19 +96,15 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ } if (!$context->null() && $constantType->getValue() === false) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( $scope->toMutatingScope(), - $exprNode, - $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), )->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === true) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( $scope->toMutatingScope(), - $exprNode, - $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), )->setRootExpr($expr); } @@ -477,10 +473,8 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() ) { - return $this->defaultNarrowingHelper->getChildSpecifiedTypes( + return ($resultFor($leftExpr) ?? $scope->toMutatingScope()->obtainResultForNode($leftExpr))->getSpecifiedTypesForScope( $scope->toMutatingScope(), - $leftExpr, - $resultFor($leftExpr), $context, )->setRootExpr($expr); } @@ -803,10 +797,8 @@ private function specifyTypesForConstantBinaryExpression( return $types; } - return $types->unionWith($this->defaultNarrowingHelper->getChildSpecifiedTypes( + return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( $scope->toMutatingScope(), - $exprNode, - $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), )->setRootExpr($rootExpr)); } @@ -817,10 +809,8 @@ private function specifyTypesForConstantBinaryExpression( return $types; } - return $types->unionWith($this->defaultNarrowingHelper->getChildSpecifiedTypes( + return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( $scope->toMutatingScope(), - $exprNode, - $resultFor($exprNode), $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), )->setRootExpr($rootExpr)); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index eb6266f253b..3376b2bc88b 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -163,7 +163,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the synthetic condition takes the on-demand bridge; its real // subnodes answer from stored results - return $this->defaultNarrowingHelper->getChildSpecifiedTypes($s, $conditionExpr, null, $context)->setRootExpr($expr); + return $s->obtainResultForNode($conditionExpr)->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); }, ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 6c24bdca743..7198bb1b023 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1241,6 +1241,18 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array * @internal */ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->obtainResultForNode($node)->getSpecifiedTypesForScope($this->toMutatingScope(), $context); + } + + /** + * Obtains the ExpressionResult of a node so its narrowing/type can be asked + * (getSpecifiedTypesForScope()/getTypeForScope()): the stored result of an + * already-processed node, or - for a synthetic node (or with no analysis in + * progress) - the result of processing it on demand against a duplicate of + * the current storage, so the throwaway walk never pollutes the live one. + */ + public function obtainResultForNode(Expr $node): ExpressionResult { // see resolveTypeOfNewWorldHandlerNode() - rules ask the dispatcher // with their FiberScope (e.g. ImpossibleCheckTypeHelper), the engine @@ -1250,7 +1262,7 @@ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierConte if ($storage !== null) { $result = $storage->findExpressionResult($node); if ($result !== null) { - return $result->getSpecifiedTypesForScope($scope, $context); + return $result; } } @@ -1260,20 +1272,18 @@ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierConte && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { throw new ShouldNotHappenException(sprintf( - 'specifyTypesOfNewWorldHandlerNode() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + 'obtainResultForNode() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', get_class($node), $node->getStartLine(), )); } // a synthetic node, or no analysis in progress - $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + return $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $node, $scope, $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), ); - - return $onDemandResult->getSpecifiedTypesForScope($scope, $context); } /** From 2df5e26ba8a94b1b9d5c2ed90840f9104298d449 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 15:14:12 +0200 Subject: [PATCH 223/378] Keep getType for the dropped-self-condition complement Upstream's boolean-decomposition complement (b8eda7b726) needs the full tracked type of the target so it can union back the values the dropped self-condition excluded (bug-14874's `array|null`). The on-demand reads re-resolve from the receiver and lose that narrowing, so the complement came out empty. Restore the merged-in getType here; converting it to the inside-out world is follow-up like the other Scope::getType removals. --- .../ExprHandler/Helper/ConditionalExpressionHolderHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index aee0397e484..10f6139f15c 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -232,7 +232,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol // holder must allow the values it excluded, or it over-narrows when // only the remaining conditions hold. So union back the complement. if ($droppedSelfCondition !== null) { - $complement = TypeCombinator::remove($nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope), $droppedSelfCondition->getType()); + $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); if (!$complement instanceof NeverType) { $holderType = TypeCombinator::union($holderType, $complement); } From cc25fe55a7efc3f21f304a536fddeaac7f0bda3d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 21:24:47 +0200 Subject: [PATCH 224/378] Add regression test for the type of a Closure::bind callback --- tests/PHPStan/Analyser/nsrt/bug-11953.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11953.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11953.php b/tests/PHPStan/Analyser/nsrt/bug-11953.php new file mode 100644 index 00000000000..af3f138d743 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11953.php @@ -0,0 +1,23 @@ + $this->id, + $foo, + Foo::class, +); + +assertType('((Closure(): int)|null)', $closure); From 80924c2ed7ca482b80af5ffc1a4864ab12618654 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 21:42:08 +0200 Subject: [PATCH 225/378] Add regression test for array value type after foreach by-ref reassignment --- tests/PHPStan/Analyser/nsrt/bug-13802.php | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13802.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13802.php b/tests/PHPStan/Analyser/nsrt/bug-13802.php new file mode 100644 index 00000000000..94ed2adc429 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13802.php @@ -0,0 +1,26 @@ + + */ +function createArray(): array { + return ['arr' => 'key']; +} + +$arr = createArray(); + +assertType('array', $arr); + +foreach ($arr as &$val) { + assertType('string', $val); + $val = preg_replace('/[^\x20-\x7E]/', '', $val); + assertType('string|null', $val); + $val = str_replace(' ', '', $val ?? ''); + assertType('string', $val); +} + +assertType('array', $arr); From be884aa211d653d6eb91a74f38d34619dee3aa3b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 21:42:08 +0200 Subject: [PATCH 226/378] Add regression test for list preserved after foreach by-ref --- tests/PHPStan/Analyser/nsrt/bug-13789.php | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13789.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13789.php b/tests/PHPStan/Analyser/nsrt/bug-13789.php new file mode 100644 index 00000000000..686b7f96e4c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13789.php @@ -0,0 +1,28 @@ +> */ +function get_list_of_non_empty_array(): array +{ + throw new \Exception(); +} + +/** @param array $row */ +function sanitize(array &$row): void { } + + +$foo = get_list_of_non_empty_array(); +assertType('list>', $foo); + +foreach ($foo as &$row) { + sanitize($row); // $row might be empty after that line + assertType('array', $row); + $row[random_bytes(2)] = random_bytes(2); // $row is definitely non-empty after that line + assertType('non-empty-array', $row); +} +unset($row); + +assertType('list>', $foo); From 8a9e1cfe9fa1c314b98c0e8e7815d8dba6a5ba47 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 30 Jun 2026 21:42:08 +0200 Subject: [PATCH 227/378] Add regression test for ??= on a dynamic property array offset --- .../Rules/Variables/NullCoalesceRuleTest.php | 5 ++++ .../Rules/Variables/data/bug-12780.php | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12780.php diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index e001d6500ed..b4873c02bd1 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -484,4 +484,9 @@ public function testBug14393(): void ]); } + public function testBug12780(): void + { + $this->analyse([__DIR__ . '/data/bug-12780.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-12780.php b/tests/PHPStan/Rules/Variables/data/bug-12780.php new file mode 100644 index 00000000000..5a7473efb56 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12780.php @@ -0,0 +1,29 @@ +missedOne = []; + $user->missedTwo = []; + $user->missedMore = []; + + $variableName = match ($count) { + 0 => null, + 1 => 'missedOne', + 2 => 'missedTwo', + default => 'missedMore', + }; + + if ($variableName !== null) { + $user->$variableName['test'] ??= 0; + $user->$variableName['test']++; + + } + } + +} From 70476f482963056b44e9f47252c36075019878bf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 14:38:43 +0200 Subject: [PATCH 228/378] Resolve ExpressionResult types from memoized per-flavour slots The typeCallback is a pure function of the nativeTypesPromoted flag, so its result is memoized once per flavour (resolveOwnType) instead of re-running the whole callback tree on every ask. getTypeForScope() and getNativeTypeForScope() are gone: reads at processing time use the cached getType()/getNativeType() pair, and the deliberately scope-sensitive consumers (isset/empty/?? chain folding, narrowing helpers, the stored-result reads in NodeScopeResolver and MutatingScope) go through the new getTypeOnScope(), where a type tracked for the whole expression on the asking scope wins over the result's own type - never the result's beforeScope holder, which may carry an ensured (e.g. non-null) type the asking scope does not. --- .../ExprHandler/ArrayDimFetchHandler.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 20 ++--- src/Analyser/ExprHandler/BinaryOpHandler.php | 6 +- .../ExprHandler/BooleanAndHandler.php | 2 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 8 +- src/Analyser/ExprHandler/CoalesceHandler.php | 6 +- src/Analyser/ExprHandler/FuncCallHandler.php | 20 ++--- .../Helper/ImplicitToStringCallHelper.php | 2 +- .../ExprHandler/InstanceofHandler.php | 4 +- src/Analyser/ExprHandler/IssetHandler.php | 4 +- src/Analyser/ExprHandler/MatchHandler.php | 4 +- .../ExprHandler/MethodCallHandler.php | 14 ++-- src/Analyser/ExprHandler/NewHandler.php | 2 +- .../ExprHandler/PropertyFetchHandler.php | 2 +- .../ExprHandler/StaticCallHandler.php | 10 +-- src/Analyser/ExprHandler/TernaryHandler.php | 6 +- src/Analyser/ExprHandler/ThrowHandler.php | 2 +- .../Virtual/FunctionCallableNodeHandler.php | 2 +- .../Virtual/MethodCallableNodeHandler.php | 2 +- src/Analyser/ExpressionResult.php | 80 +++++++++++-------- src/Analyser/IssetabilityDescriptor.php | 4 +- src/Analyser/MutatingScope.php | 23 +++--- src/Analyser/NodeScopeResolver.php | 30 +++---- src/Rules/IssetCheck.php | 10 +++ 24 files changed, 143 insertions(+), 122 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 5e39abd7814..203944b6433 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $scope = $varResult->getScope(); - $varType = $varResult->getTypeForScope($scope); + $varType = $varResult->getType(); $offsetGetResult = null; if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f0e0a2a30b6..beaa2bcee2d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -658,8 +658,8 @@ public function processAssignVar( // process the dimension first, then consume its ExpressionResult // (single-pass inside-out) rather than reading it before processExprNode() $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); - $offsetTypes[] = [$result->getTypeForScope($scope), $dimFetch]; - $offsetNativeTypes[] = [$result->getNativeTypeForScope($scope), $dimFetch]; + $offsetTypes[] = [$result->getType(), $dimFetch]; + $offsetNativeTypes[] = [$result->getNativeType(), $dimFetch]; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -699,8 +699,8 @@ public function processAssignVar( $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scopeBeforeAssignEval); - $varType = $varResult->getTypeForScope($scope); - $varNativeType = $varResult->getNativeTypeForScope($scope); + $varType = $varResult->getType(); + $varNativeType = $varResult->getNativeType(); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -828,7 +828,7 @@ public function processAssignVar( $throwPoints[] = InternalThrowPoint::createImplicit($scope, $var); } - $propertyHolderType = $objectResult->getTypeForScope($scope); + $propertyHolderType = $objectResult->getType(); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); @@ -913,7 +913,7 @@ public function processAssignVar( $propertyHolderType = $scope->resolveTypeByName($var->class); } else { $classResult = $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); - $propertyHolderType = $classResult->getTypeForScope($scope); + $propertyHolderType = $classResult->getType(); } $propertyName = null; @@ -1053,14 +1053,14 @@ public function processAssignVar( foreach (array_reverse($dimFetchStack) as $dimFetch) { $dimExpr = $dimFetch->getDim(); $dimResult = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $offsetTypes[] = [$dimResult->getTypeForScope($scope), $dimFetch]; - $offsetNativeTypes[] = [$dimResult->getNativeTypeForScope($scope), $dimFetch]; + $offsetTypes[] = [$dimResult->getType(), $dimFetch]; + $offsetNativeTypes[] = [$dimResult->getNativeType(), $dimFetch]; } $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope); - $varType = $varResult->getTypeForScope($scope); - $varNativeType = $varResult->getNativeTypeForScope($scope); + $varType = $varResult->getType(); + $varNativeType = $varResult->getNativeType(); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 704ca888bbc..434dea3bed8 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -97,7 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && // the right operand was just processed on $leftResult's scope; read its // result instead of re-walking via Scope::getType(). - !$rightResult->getTypeForScope($leftResult->getScope())->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$rightResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } @@ -643,8 +643,8 @@ private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr, Ex } // the operands were processed during processExpr; use their results' types. - $leftType = $leftResult->getTypeForScope($scope); - $rightType = $rightResult->getTypeForScope($scope); + $leftType = $leftResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $rightType = $rightResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 822ac22297f..682dca47ec9 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftTruthyScope = $leftResult->getTruthyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getFalseyScope(); } else { diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index cb8e9f4c526..b980fb9ed3e 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -133,7 +133,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftFalseyScope = $leftResult->getFalseyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getTruthyScope(); } else { @@ -187,12 +187,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($context->true()) { if ( - $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + $leftResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() ) { $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); } elseif ( - $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() - || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + $leftResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isTrue()->yes() + || $rightResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() ) { $types = $leftTypes->normalize($s, $nodeScopeResolver); } else { diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 22175f08bd6..15996fb19e7 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // mid-processing would take the on-demand path and recurse $rightScope = $scope->applySpecifiedTypes($this->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); - $rightExprType = $rightResult->getTypeForScope($scope); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); } else { @@ -133,7 +133,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->getFalseySpecifiedTypes($s, $expr, $condResult, $context); } - if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeForScope($s)->toBoolean())->yes()) { + if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean())->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); } @@ -147,7 +147,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // TypeSpecifier::create() recovered by unwrapping the coalesce createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { if (!$context->null()) { - $rightType = $rightResult->getTypeForScope($s); + $rightType = $rightResult->getTypeOnScope($s, $s->nativeTypesPromoted); if ( ($context->true() && $type->isSuperTypeOf($rightType)->no()) || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 1c84ef4f741..d1929b7760e 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -133,7 +133,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // process the dynamic callee name first, then consume its type (single-pass // inside-out) rather than reading it before processExprNode() stores it $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); - $nameType = $nameResult->getTypeForScope($scope); + $nameType = $nameResult->getType(); if (!$nameType->isCallable()->no()) { $variants = $nameType->getCallableParametersAcceptors($scope); // A structural acceptor (names/positions/variadic) drives the per-arg @@ -203,7 +203,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // so the NoopNodeCallback here avoids duplicate node-callbacks. $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $clonePropertiesArgType = $clonePropertiesArgResult->getTypeForScope($scope); + $clonePropertiesArgType = $clonePropertiesArgResult->getType(); $cloneExpr = new TypeExpr($scope->getType(new Expr\Clone_($normalizedExpr->getArgs()[0]->value))); $clonePropertiesArgTypeConstantArrays = $clonePropertiesArgType->getConstantArrays(); foreach ($clonePropertiesArgTypeConstantArrays as $clonePropertiesArgTypeConstantArray) { @@ -541,17 +541,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ) { $arrayArg = $normalizedExpr->getArgs()[0]->value; $arrayArgResult = $argsResult->getArgResult($arrayArg); - $arrayArgType = $arrayArgResult !== null ? $arrayArgResult->getTypeForScope($scope) : $scope->getType($arrayArg); - $arrayArgNativeType = $arrayArgResult !== null ? $arrayArgResult->getNativeTypeForScope($scope) : $scope->getNativeType($arrayArg); + $arrayArgType = $arrayArgResult !== null ? $arrayArgResult->getType() : $scope->getType($arrayArg); + $arrayArgNativeType = $arrayArgResult !== null ? $arrayArgResult->getNativeType() : $scope->getNativeType($arrayArg); $offsetArg = $normalizedExpr->getArgs()[1]->value; $offsetArgResult = $argsResult->getArgResult($offsetArg); - $offsetType = $offsetArgResult !== null ? $offsetArgResult->getTypeForScope($scopeBeforeArgs) : $scopeBeforeArgs->getType($offsetArg); + $offsetType = $offsetArgResult !== null ? $offsetArgResult->getType() : $scopeBeforeArgs->getType($offsetArg); if (isset($normalizedExpr->getArgs()[2])) { $lengthArg = $normalizedExpr->getArgs()[2]->value; $lengthArgResult = $argsResult->getArgResult($lengthArg); - $lengthType = $lengthArgResult !== null ? $lengthArgResult->getTypeForScope($scopeBeforeArgs) : $scopeBeforeArgs->getType($lengthArg); + $lengthType = $lengthArgResult !== null ? $lengthArgResult->getType() : $scopeBeforeArgs->getType($lengthArg); } else { $lengthType = new NullType(); } @@ -771,7 +771,7 @@ private function getArrayFunctionAppendingType(FunctionReflection $functionRefle $arrayArgResult = $argsResult->getArgResult($arrayArg); // closure args have no ExpressionResult (ProcessClosureResult carries none); // they fall back to the scope, every other arg reads its captured result. - $arrayType = $arrayArgResult !== null ? $arrayArgResult->getTypeForScope($scope->toMutatingScope()) : $scope->getType($arrayArg); + $arrayType = $arrayArgResult !== null ? $arrayArgResult->getTypeOnScope($scope->toMutatingScope(), $scope->toMutatingScope()->nativeTypesPromoted) : $scope->getType($arrayArg); $callArgs = array_slice($expr->getArgs(), 1); /** @@ -781,7 +781,7 @@ private function getArrayFunctionAppendingType(FunctionReflection $functionRefle $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null) use ($argsResult): void { foreach ($callArgs as $callArg) { $callArgResult = $argsResult->getArgResult($callArg->value); - $callArgType = $callArgResult !== null ? $callArgResult->getTypeForScope($scope->toMutatingScope()) : $scope->getType($callArg->value); + $callArgType = $callArgResult !== null ? $callArgResult->getTypeOnScope($scope->toMutatingScope(), $scope->toMutatingScope()->nativeTypesPromoted) : $scope->getType($callArg->value); if ($callArg->unpack) { $constantArrays = $callArgType->getConstantArrays(); if (count($constantArrays) === 1) { @@ -1127,7 +1127,7 @@ private function specifyTypesFromCallableCall(NodeScopeResolver $nodeScopeResolv } $calleeType = $nameResult !== null - ? $nameResult->getTypeForScope($scope) + ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($call->name, $scope); $assertions = null; @@ -1195,7 +1195,7 @@ private function isFuncCallNarrowable(NodeScopeResolver $nodeScopeResolver, Muta } $nameType = $nameResult !== null - ? $nameResult->getTypeForScope($scope) + ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); if (!$nameType->isCallable()->yes()) { return true; diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index cf79506826b..1c8861e0985 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -41,7 +41,7 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver $impurePoints = []; $exprType = $exprResult !== null - ? $exprResult->getTypeForScope($scope) + ? $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $toStringMethod = null; diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index e45628e1d3b..05d58dd7271 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -171,7 +171,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($classResult === null) { throw new ShouldNotHappenException(); } - $classNameType = $classResult->getTypeForScope($s); + $classNameType = $classResult->getTypeOnScope($s, $s->nativeTypesPromoted); $result = $classNameType->toObjectTypeForInstanceofCheck(); $type = $result->type; $uncertainty = $result->uncertainty; @@ -184,7 +184,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } elseif ($context->false() && !$uncertainty) { - $exprType = $exprResult->getTypeForScope($s); + $exprType = $exprResult->getTypeOnScope($s, $s->nativeTypesPromoted); if (!$type->isSuperTypeOf($exprType)->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 7dd2f5fd08e..149728c9ab7 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -123,7 +123,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // The subjects and their chain links were just processed, so their // ExpressionResults are in the storage; capture them (the results, not the // storage - no reference cycle) so the narrowing reads their types via - // getTypeForScope() instead of re-walking through Scope::getType(). + // getTypeOnScope() instead of re-walking through Scope::getType(). $chainResults = []; foreach ($expr->vars as $var) { $this->captureChainResults($var, $storage, $chainResults); @@ -174,7 +174,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $readType = static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { $result = $chainResults[spl_object_id($e)] ?? null; - return $result !== null ? $result->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); + return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); }; if (count($expr->vars) === 0 || $context->null()) { diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index f0178e2db55..c564579e5c0 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -211,8 +211,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $deepContext); // the subject was just processed on this scope; read its result instead of // re-walking via Scope::getType(). - $condType = $condResult->getTypeForScope($scope); - $condNativeType = $condResult->getNativeTypeForScope($scope); + $condType = $condResult->getType(); + $condNativeType = $condResult->getNativeType(); $scope = $condResult->getScope(); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index dc3431f85c9..76eff55d1f8 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -92,8 +92,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // argument; the NoopNodeCallback here avoids a duplicate node-callback. $newThisResult = $nodeScopeResolver->processExprNode($stmt, $expr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $closureCallScope = $scope->enterClosureCall( - $newThisResult->getTypeForScope($scope), - $newThisResult->getNativeTypeForScope($scope), + $newThisResult->getType(), + $newThisResult->getNativeType(), ); } @@ -113,7 +113,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = null; // the var was processed above as the receiver; read its already-computed // result instead of re-walking via Scope::getType(). - $calledOnType = $varResult->getTypeForScope($scope); + $calledOnType = $varResult->getType(); // A call configured as early-terminating never returns: give it an explicit // never so the statement's exit point follows from the result type, instead of // NodeScopeResolver re-deriving it via Scope::getType(). @@ -278,7 +278,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createCovariant(), ), - $varResult->getNativeTypeForScope($scope), + $varResult->getNativeType(), ); } } @@ -316,7 +316,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the var was processed above as the receiver; read its already-computed // result on the original scope instead of re-walking via Scope::getType(). - $calledOnType = $varResult->getTypeForScope($originalScope); + $calledOnType = $varResult->getType(); if (!$expr->name instanceof Identifier) { return $result; } @@ -447,7 +447,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop // the var was processed during processExpr; read its already-computed // result instead of re-walking via Scope::getType(). - $methodCalledOnType = $varResult->getTypeForScope($scope); + $methodCalledOnType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $methodReflection = $scope->getMethodReflection($methodCalledOnType, $expr->name->name); if ($methodReflection !== null) { $args = $expr->getArgs(); @@ -520,7 +520,7 @@ private function isMethodCallNarrowable(MutatingScope $scope, Expr $expr, Expres return true; } - $calledOnType = $varResult->getTypeForScope($scope); + $calledOnType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); if ($methodReflection === null) { return false; diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 4eb414c1f55..3f3df40630a 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -184,7 +184,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // The instantiated object type derives from the class expression - read // its already-processed result rather than asking Scope::getType() for // the not-yet-stored New_ node, which would re-enter this handler. - $objectClasses = $classResult->getTypeForScope($scope)->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); + $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index ffbe96769ea..25f7f446ef8 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -64,7 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = null; if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); - $propertyHolderType = $varResult->getTypeForScope($scopeBeforeVar); + $propertyHolderType = $varResult->getType(); $propertyReflection = $scopeBeforeVar->getInstancePropertyReflection($propertyHolderType, $propertyName); if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index a8cb5bd2414..1124f959f6d 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -108,7 +108,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->name instanceof Identifier) { $earlyTerminatingClassType = $expr->class instanceof Name ? $scope->resolveTypeByName($expr->class) - : $classResult->getTypeForScope($scope); + : $classResult->getType(); $isEarlyTerminating = $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $earlyTerminatingClassType); } $isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating; @@ -181,7 +181,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($expr->class instanceof Expr) { // the class expr was processed above as the receiver; read its // already-computed result instead of re-walking via Scope::getType(). - $classType = $classResult->getTypeForScope($scope)->getObjectTypeOrClassStringObjectType(); + $classType = $classResult->getType()->getObjectTypeOrClassStringObjectType(); $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { @@ -201,7 +201,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Expr) { // the class expr was processed above as the receiver; read its // already-computed result instead of re-walking via Scope::getType(). - $objectClasses = $classResult->getTypeForScope($scope)->getObjectClassNames(); + $objectClasses = $classResult->getType()->getObjectClassNames(); if (count($objectClasses) !== 1) { $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); } @@ -518,7 +518,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop // the class expr was processed during processExpr; read its // already-computed result instead of re-walking via Scope::getType(). $calleeType = $classResult !== null - ? $classResult->getTypeForScope($scope) + ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); } @@ -598,7 +598,7 @@ private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?Expre $calleeType = $scope->resolveTypeByName($expr->class); } else { $calleeType = $classResult !== null - ? $classResult->getTypeForScope($scope) + ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 3376b2bc88b..349bf45f3d8 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -67,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $hasYield = $hasYield || $ifResult->hasYield(); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifResult->getTypeForScope($ifTrueScope); + $ifTrueType = $ifResult->getType(); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } - $condType = $ternaryCondResult->getTypeForScope($scope); + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; } elseif ($condType->isFalse()->yes()) { @@ -85,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $elseResult->getTypeForScope($ifFalseScope); + $ifFalseType = $elseResult->getType(); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index af65c3b2a00..37799e2b7be 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -49,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex expr: $expr, hasYield: false, isAlwaysTerminating: true, - throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getTypeForScope($scope), $expr, false)]), + throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getType(), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index 61f41a65e84..a64bc4931ff 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -81,7 +81,7 @@ private function resolveType(MutatingScope $scope, FunctionCallableNode $expr, ? if ($nameResult === null) { throw new ShouldNotHappenException(); } - $callableType = $nameResult->getTypeForScope($scope); + $callableType = $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); if (!$callableType->isCallable()->yes()) { return new ObjectType(Closure::class); } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index d75d02529a5..1f65ec270ba 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -82,7 +82,7 @@ private function resolveType(MutatingScope $scope, MethodCallableNode $expr, Exp // $originalNode->var is the same node as $expr->getVar(), processed in // processExpr - read its ExpressionResult instead of Scope::getType() - $varType = $varResult->getTypeForScope($scope); + $varType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $method = $scope->getMethodReflection($varType, $originalNode->name->toString()); if ($method === null) { return new ObjectType(Closure::class); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index d5be16d74e8..1f95c69379e 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -30,6 +30,10 @@ final class ExpressionResult private ?Type $cachedNativeType = null; + private ?Type $resolvedType = null; + + private ?Type $resolvedNativeType = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints @@ -113,7 +117,7 @@ public function getIssetabilityResolution(MutatingScope $scope, bool $useNativeT return $this->issetabilityDescriptor->resolve($scope, $useNativeTypes, $this->expr); } - $type = $useNativeTypes ? $this->getNativeTypeForScope($scope) : $this->getTypeForScope($scope); + $type = $this->getTypeOnScope($scope, $useNativeTypes); return new IssetabilityResolution( IssetabilityLinkInfo::leaf($type, $this->expr, $this->expr instanceof Expr\NullsafePropertyFetch), @@ -198,7 +202,7 @@ public function getType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { - return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); + return $this->cachedType = $this->resolveOwnType(false); } // The guard above leaves only one way here: the expression is tracked on @@ -218,7 +222,7 @@ public function getNativeType(): Type } if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { - return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + return $this->cachedNativeType = $this->resolveOwnType(true); } // Tracked native holder (getNativeType() promotes the scope, so its @@ -226,6 +230,34 @@ public function getNativeType(): Type return $this->cachedNativeType = $this->beforeScope->doNotTreatPhpDocTypesAsCertain()->getTrackedExpressionType($this->expr); } + /** + * The result's own type - the eager value or the memoized typeCallback, + * with no tracked-holder interference. The callback is a pure function of + * the flavour flag, so one memo slot per flavour is exact. + */ + private function resolveOwnType(bool $nativeTypesPromoted): Type + { + if ($nativeTypesPromoted) { + if ($this->nativeType !== null) { + return $this->nativeType; + } + if ($this->typeCallback === null) { + throw new ShouldNotHappenException(); + } + + return $this->resolvedNativeType ??= TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + } + + if ($this->type !== null) { + return $this->type; + } + if ($this->typeCallback === null) { + throw new ShouldNotHappenException(); + } + + return $this->resolvedType ??= TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); + } + /** * A narrowed or ensured type tracked for the whole expression (e.g. the * nullsafe handlers ensure `($x ?? null)` is not null before processing @@ -277,42 +309,20 @@ public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSp } /** - * Re-evaluates the expression type on a different scope (e.g. a narrowed one). - * Unlike getType(), the result is not cached. + * The type of this expression as the given scope sees it: a narrowed or + * ensured type the scope tracks for the whole expression wins over the + * result's own (position-time) type. For the deliberately scope-sensitive + * consumers - isset/empty/?? chain folding and the stored-result read in + * NodeScopeResolver - everything else reads getType()/getNativeType(). */ - public function getTypeForScope(MutatingScope $scope): Type + public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type { - // A native-promoted scope asks getType() but means the native flavour - // (MutatingScope::getNativeType() promotes then calls getType()); the - // eager value is stored as a (phpdoc, native) pair, so honour the scope. - if ($this->nativeType !== null && $scope->nativeTypesPromoted) { - return $this->nativeType; - } - - if ($this->type !== null) { - return $this->type; - } - - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->nativeTypesPromoted)); - } - - return $scope->getTrackedExpressionType($this->expr); - } - - /** Native counterpart of getTypeForScope(). */ - public function getNativeTypeForScope(MutatingScope $scope): Type - { - if ($this->nativeType !== null) { - return $this->nativeType; - } - - $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { - return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + if ($this->type === null && $this->hasTrackedExpressionType($readScope)) { + return $readScope->getTrackedExpressionType($this->expr); } - return $nativeScope->getTrackedExpressionType($this->expr); + return $this->resolveOwnType($useNativeTypes); } } diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index 501da858ed4..d31bf4534c1 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -96,8 +96,8 @@ public function resolve(MutatingScope $scope, bool $useNativeTypes, Expr $expr): throw new ShouldNotHappenException(); } - $varType = $useNativeTypes ? $varResult->getNativeTypeForScope($scope) : $varResult->getTypeForScope($scope); - $dimType = $useNativeTypes ? $dimResult->getNativeTypeForScope($scope) : $dimResult->getTypeForScope($scope); + $varType = $varResult->getTypeOnScope($scope, $useNativeTypes); + $dimType = $dimResult->getTypeOnScope($scope, $useNativeTypes); $hasOffsetValue = $varType->hasOffsetValueType($dimType); $valueType = $hasOffsetValue->no() ? new NeverType() : $varType->getOffsetValueType($dimType); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7198bb1b023..27feda8afa5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1151,7 +1151,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type if ($storage !== null) { $result = $storage->findExpressionResult($node); if ($result !== null && $result->canResolveOwnType()) { - return $scope->nativeTypesPromoted ? $result->getNativeTypeForScope($scope) : $result->getTypeForScope($scope); + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); } } @@ -1181,7 +1181,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), ); - return $scope->nativeTypesPromoted ? $onDemandResult->getNativeTypeForScope($scope) : $onDemandResult->getTypeForScope($scope); + return $onDemandResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); } /** @@ -1217,18 +1217,19 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array ); return [ - $result->getTypeForScope($scope), - $result->getNativeTypeForScope($scope), + $result->getTypeOnScope($scope, $scope->nativeTypesPromoted), + $result->getTypeOnScope($scope, true), ]; } - // re-evaluate on the asking scope, not the stored beforeScope: a handler - // (e.g. isset/empty via NonNullabilityHelper) may have processed the - // inner expression on a scope that strips null, so the cached type would - // be stale for the narrowing the caller is applying + // a type tracked for the whole expression on the asking scope wins over + // the stored result's own type: a handler (e.g. isset/empty via + // NonNullabilityHelper) may have processed the inner expression on a + // scope that strips null, so the result's type would be stale for the + // narrowing the caller is applying return [ - $result->getTypeForScope($this), - $result->getNativeTypeForScope($this), + $result->getTypeOnScope($this, $this->nativeTypesPromoted), + $result->getTypeOnScope($this, true), ]; } @@ -1247,7 +1248,7 @@ public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierConte /** * Obtains the ExpressionResult of a node so its narrowing/type can be asked - * (getSpecifiedTypesForScope()/getTypeForScope()): the stored result of an + * (getSpecifiedTypesForScope()/getTypeOnScope()): the stored result of an * already-processed node, or - for a synthetic node (or with no analysis in * progress) - the result of processing it on demand against a duplicate of * the current storage, so the throwaway walk never pollutes the live one. diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 33a7d252c76..eb45922daac 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1457,7 +1457,7 @@ public function processStmtNode( foreach ($stmt->elseifs as $elseif) { $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); - $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condScope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); + $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); $branchScopeStatementResult = $this->processStmtNodesInternal($elseif, $elseif->stmts, $condResult->getTruthyScope(), $storage, $nodeCallback, $context); @@ -1564,8 +1564,8 @@ public function processStmtNode( $originalScope = $scope; $bodyScope = $scope; - $foreachIterateeType = $condResult->getTypeForScope($originalScope); - $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); + $foreachIterateeType = $condResult->getType(); + $foreachNativeIterateeType = $condResult->getNativeType(); if ($stmt->keyVar instanceof Variable) { $keyTypeExpr = new NativeTypeExpr( @@ -1834,7 +1834,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($scope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1890,7 +1890,7 @@ public function processStmtNode( $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyCondResult->getTypeForScope($bodyScopeMaybeRan) : $bodyCondResult->getTypeForScope($bodyScopeMaybeRan->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyCondResult->getType() : $bodyCondResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -2059,7 +2059,7 @@ public function processStmtNode( // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condResultScope) : $condResult->getNativeTypeForScope($condResultScope))->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } @@ -2620,7 +2620,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } else { $constantName = new Name\FullyQualified($const->name->toString()); } - $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getTypeForScope($scope), $constResult->getNativeTypeForScope($scope)); + $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getType(), $constResult->getNativeType()); } } elseif ($stmt instanceof Node\Stmt\ClassConst) { $hasYield = false; @@ -2636,8 +2636,8 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } $scope = $scope->assignExpression( new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), - $constResult->getTypeForScope($scope), - $constResult->getNativeTypeForScope($scope), + $constResult->getType(), + $constResult->getNativeType(), ); } } elseif ($stmt instanceof Node\Stmt\EnumCase) { @@ -2895,7 +2895,7 @@ public function readStoredOrPriceOnDemand(Expr $expr, MutatingScope $scope): Typ $current = $scope->getCurrentExpressionResultStorage(); $result = $current?->findExpressionResult($expr); if ($result !== null) { - return $result->getTypeForScope($scope); + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); } $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); @@ -2939,7 +2939,7 @@ public function priceSyntheticOnDemand(Expr $expr, MutatingScope $scope): Type { $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); - return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeForScope($scope); + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeOnScope($scope, $scope->nativeTypesPromoted); } /** Native counterpart of readStoredOrPriceOnDemand(). */ @@ -2948,7 +2948,7 @@ public function readStoredOrPriceOnDemandNative(Expr $expr, MutatingScope $scope $current = $scope->getCurrentExpressionResultStorage(); $result = $current?->findExpressionResult($expr); if ($result !== null) { - return $result->getNativeTypeForScope($scope); + return $result->getTypeOnScope($scope, true); } $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); @@ -2961,7 +2961,7 @@ public function priceSyntheticOnDemandNative(Expr $expr, MutatingScope $scope): { $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); - return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getNativeTypeForScope($scope); + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeOnScope($scope, true); } /** @@ -4277,7 +4277,7 @@ public function processArgs( } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); $argResults[spl_object_id($arg->value)] = $exprResult; - $exprType = $exprResult->getTypeForScope($scope); + $exprType = $exprResult->getType(); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -4304,7 +4304,7 @@ public function processArgs( } } - $gatheredArgTypeByIndex[$i] = $exprResult->getTypeForScope($scope); + $gatheredArgTypeByIndex[$i] = $exprResult->getType(); $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); } diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index eff4ed98840..f0aa3f7f76a 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -59,6 +59,16 @@ private function doCheck(IssetabilityResolution $resolution, MutatingScope $scop { $link = $resolution->getLink(); $inner = $resolution->getInner(); + if (getenv('ISSET_DEBUG') !== false) { + $fetch = $link->isProperty() ? $link->getPropertyFetch() : null; + fwrite(STDERR, sprintf( + "LINK line=%d prop=%s hasExprTypeOfFetch=%s writable=%s\n", + $fetch !== null ? $fetch->getStartLine() : ($link->isVariable() ? -1 : -2), + $fetch !== null && $fetch->name instanceof \PhpParser\Node\Identifier ? $fetch->name->name : '?', + $link->isProperty() ? ($link->hasExpressionTypeOfFetch() ? 'Y' : 'N') : '-', + $link->isProperty() && $link->getPropertyReflection() !== null ? $link->getPropertyReflection()->getWritableType()->describe(\PHPStan\Type\VerbosityLevel::precise()) : '-', + )); + } if ($link->isVariable()) { $hasVariable = $link->getHasVariable(); From 6644eaf72c47bd93b8bd2998446be8dddc27f1a8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 15:03:25 +0200 Subject: [PATCH 229/378] Split readStoredOrPriceOnDemand into decisive primitives The four read-or-silently-reprocess methods (readStoredOrPriceOnDemand, its Native twin, priceSyntheticOnDemand, priceSyntheticOnDemandNative) are gone. Call sites now state what they know: - processSyntheticOnDemand() - the node is one the handler built itself; processed on a duplicate of the current storage, result returned, the PHPSTAN_GUARD_NW diagnostic guards against real unprocessed nodes. - findStoredResult() - a nullable stored-result lookup. - readTypeOfMaybeStored() - the one remaining read-or-reprocess shape, its every caller explicitly marked UNDECIDED about whether the node was already analysed; the burn-down list for consuming ExpressionResults where the node is processed. A scope-based throwing readStoredResult() turned out to be unsound: loop-convergence passes rebind a local duplicate storage without pushing it on the scope's storage stack, so scope-based lookups miss nodes processed in the current pass (the old code silently re-priced them there). Consuming the result where it is produced - or a storage-parameter variant - is the correct strengthening, done per-site in follow-ups. --- src/Analyser/ExprHandler/AssignHandler.php | 98 ++++++++--------- src/Analyser/ExprHandler/AssignOpHandler.php | 8 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 6 +- src/Analyser/ExprHandler/FuncCallHandler.php | 10 +- .../ConditionalExpressionHolderHelper.php | 12 +-- .../Helper/EqualityTypeSpecifyingHelper.php | 14 +-- .../Helper/ImplicitToStringCallHelper.php | 4 +- .../Helper/NonNullabilityHelper.php | 12 +-- src/Analyser/ExprHandler/IssetHandler.php | 4 +- src/Analyser/ExprHandler/MatchHandler.php | 16 +-- .../ExprHandler/MethodCallHandler.php | 6 +- src/Analyser/ExprHandler/NewHandler.php | 2 +- .../ExprHandler/NullsafeMethodCallHandler.php | 8 +- .../NullsafePropertyFetchHandler.php | 8 +- .../ExprHandler/PropertyFetchHandler.php | 2 +- .../ExprHandler/StaticCallHandler.php | 14 +-- .../StaticPropertyFetchHandler.php | 4 +- .../ExprHandler/UnaryMinusHandler.php | 2 +- .../ExprHandler/Virtual/IssetExprHandler.php | 2 +- .../Virtual/PossiblyImpureCallExprHandler.php | 2 +- src/Analyser/NodeScopeResolver.php | 100 +++++++----------- src/Analyser/RicherScopeGetTypeHelper.php | 4 +- src/Analyser/SpecifiedTypes.php | 2 +- 24 files changed, 156 insertions(+), 188 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index beaa2bcee2d..25a91f1537d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -186,8 +186,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); - $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + $type = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $nativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -227,8 +227,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } return $nativeTypesPromoted - ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->expr, $beforeScope) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $beforeScope); + ? $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $beforeScope->doNotTreatPhpDocTypesAsCertain()) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $beforeScope); }, specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, @@ -284,7 +284,7 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver && count($expr->expr->getArgs()) >= 1 ) { $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); if ($arrayType->isArray()->yes()) { if ($context->true()) { @@ -302,7 +302,7 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), ); } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { - $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s); + $keyType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $s); $nonNullKeyType = TypeCombinator::removeNull($keyType); if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( @@ -328,14 +328,14 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver if ($funcName === 'array_search') { $arrayArg = $expr->expr->getArgs()[1]->value; $sentinelType = new ConstantBooleanType(false); - $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[2]->value, $s)->isTrue()->yes(); + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[2]->value, $s)->isTrue()->yes(); } elseif ($funcName === 'array_find_key') { $arrayArg = $expr->expr->getArgs()[0]->value; $sentinelType = new NullType(); } if ($arrayArg !== null) { - $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); if ($arrayType->isArray()->yes()) { if ($context->true()) { @@ -346,7 +346,7 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); if ($isStrictArraySearch) { - $needleType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[0]->value, $s); + $needleType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[0]->value, $s); $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); } else { $dimFetchType = $arrayType->getIterableValueType(); @@ -356,11 +356,11 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $dimFetchType, TypeSpecifierContext::createTrue()), ); } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { - $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s); + $keyType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $s); $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); if (!$narrowedKeyType instanceof NeverType) { if ($isStrictArraySearch) { - $needleType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[0]->value, $s); + $needleType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[0]->value, $s); $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); } else { $dimFetchType = $arrayType->getIterableValueType(); @@ -390,12 +390,12 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver $numArg = $args[1]->value; } $one = new ConstantIntegerType(1); - $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); if ( $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($nodeScopeResolver->readStoredOrPriceOnDemand($numArg, $s))->yes()) + && ($numArg === null || $one->isSuperTypeOf($nodeScopeResolver->readTypeOfMaybeStored($numArg, $s))->yes()) ) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); @@ -417,7 +417,7 @@ private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver && count($expr->expr->left->getArgs()) >= 1 ) { $arrayArg = $expr->expr->left->getArgs()[0]->value; - $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); if ( $arrayType->isList()->yes() && $arrayType->isIterableAtLeastOnce()->yes() @@ -472,7 +472,7 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); + $type = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { @@ -485,8 +485,8 @@ public function processAssignVar( $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $nodeScopeResolver->readStoredOrPriceOnDemand($if, $truthyScope); - $falseyType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr->else, $falsyScope); + $truthyType = $nodeScopeResolver->readTypeOfMaybeStored($if, $truthyScope); + $falseyType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr->else, $falsyScope); if ( $truthyType->isSuperTypeOf($falseyType)->no() @@ -559,7 +559,7 @@ public function processAssignVar( } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope), TrinaryLogic::createYes()); + $scope = $scope->assignVariable($var->name, $type, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } @@ -671,7 +671,7 @@ public function processAssignVar( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimFetch->var, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope)), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope)), specifyTypesCallback: static fn () => new SpecifiedTypes(), )); $scope = $result->getScope(); @@ -696,8 +696,8 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); - $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scopeBeforeAssignEval); + $valueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval->doNotTreatPhpDocTypesAsCertain()); $varType = $varResult->getType(); $varNativeType = $varResult->getNativeType(); @@ -751,7 +751,7 @@ public function processAssignVar( if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope), $var->name->toString()); } } $scope = $scope->assignExpression( @@ -766,7 +766,7 @@ public function processAssignVar( } elseif ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope), $var->name->toString()); } } } @@ -781,7 +781,7 @@ public function processAssignVar( $scope = $scope->assignExpression($expr, $type, $nativeType); } - $setVarType = $nodeScopeResolver->readStoredOrPriceOnDemand($originalVar->var, $scope); + $setVarType = $nodeScopeResolver->readTypeOfMaybeStored($originalVar->var, $scope); if ( !$setVarType instanceof ErrorType && !$setVarType->isArray()->yes() @@ -831,7 +831,7 @@ public function processAssignVar( $propertyHolderType = $objectResult->getType(); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); - $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $assignedExprType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -848,16 +848,16 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } $declaringClass = $propertyReflection->getDeclaringClass(); @@ -892,9 +892,9 @@ public function processAssignVar( } } else { // fallback - $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $assignedExprType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); // simulate dynamic property assign by __set to get throw points if (!$propertyHolderType->hasMethod('__set')->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( @@ -938,7 +938,7 @@ public function processAssignVar( if ($propertyName !== null) { $propertyReflection = $scope->getStaticPropertyReflection($propertyHolderType, $propertyName); - $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $assignedExprType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -955,23 +955,23 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } } else { // fallback - $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $assignedExprType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } elseif ($var instanceof List_) { $result = $processExprCallback($scope); @@ -1005,7 +1005,7 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } - $getOffsetValueTypeExpr = new TypeExpr($nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope))); + $getOffsetValueTypeExpr = new TypeExpr($nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope))); $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -1057,8 +1057,8 @@ public function processAssignVar( $offsetNativeTypes[] = [$dimResult->getNativeType(), $dimFetch]; } - $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); - $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope); + $valueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); + $nativeValueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()); $varType = $varResult->getType(); $varNativeType = $varResult->getNativeType(); @@ -1236,7 +1236,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeSco /** * Current type of a conditional-holder expression, used to refine the holder's - * projected type. Prefers the tracked scope state over readStoredOrPriceOnDemand(), + * projected type. Prefers the tracked scope state over readTypeOfMaybeStored(), * whose stored ExpressionResult can be stale after a by-ref write - e.g. * preg_match($p, $s, $matches) updates $matches in the scope state but leaves the * stored result from the earlier `$matches = []` untouched, so reading it back would @@ -1252,7 +1252,7 @@ private function currentTypeForConditionalHolder(NodeScopeResolver $nodeScopeRes return $scope->getVariableType($expr->name); } - return $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + return $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); } /** @@ -1453,7 +1453,7 @@ private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Mu $implicitIndex = 0; foreach ($arrayExpr->items as $arrayItem) { if ($arrayItem->key !== null) { - $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayItem->key, $scope)->toArrayKey(); + $keyType = $nodeScopeResolver->readTypeOfMaybeStored($arrayItem->key, $scope)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1488,8 +1488,8 @@ private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Mu $refVarName = $arrayItem->value->name; $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $refType = $nodeScopeResolver->readStoredOrPriceOnDemand(new Variable($refVarName), $scope); - $refNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative(new Variable($refVarName), $scope); + $refType = $nodeScopeResolver->processSyntheticOnDemand(new Variable($refVarName), $scope)->getTypeOnScope($scope, false); + $refNativeType = $nodeScopeResolver->processSyntheticOnDemand(new Variable($refVarName), $scope)->getTypeOnScope($scope, true); // When $rootVarName's array key changes, update $refVarName $scope = $scope->assignExpression( @@ -1536,14 +1536,14 @@ private function produceArrayDimFetchAssignValueToWrite(NodeScopeResolver $nodeS $has = $offsetValueType->hasOffsetValueType($offsetType); if ($has->yes()) { if ($scope->hasExpressionType($dimFetch)->yes()) { - $offsetValueType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch, $scope); + $offsetValueType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch, $scope); } else { $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); } } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch, $scope); + $offsetValueType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch, $scope); } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } @@ -1644,7 +1644,7 @@ private function produceArrayDimFetchAssignValueToWrite(NodeScopeResolver $nodeS } elseif (isset($computedContainerValues[$key])) { $additionalValueType = $computedContainerValues[$key]; } else { - $offsetType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->dim, $scope); + $offsetType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->dim, $scope); $additionalValueType = $valueToWrite->getOffsetValueType($offsetType); } @@ -1680,7 +1680,7 @@ private function shouldKeepList(NodeScopeResolver $nodeScopeResolver, ArrayDimFe && in_array($arrayDimFetch->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($arrayDimFetch->dim->left->getArgs()) === 1 // could support COUNT_RECURSIVE, COUNT_NORMAL && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->left->getArgs()[0]->value) - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($nodeScopeResolver->readStoredOrPriceOnDemand($arrayDimFetch->dim, $scope))->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($nodeScopeResolver->readTypeOfMaybeStored($arrayDimFetch->dim, $scope))->yes() && $offsetValueType->isIterableAtLeastOnce()->yes() ) { return true; @@ -1723,7 +1723,7 @@ private function getOriginalPropertyType(NodeScopeResolver $nodeScopeResolver, P $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); if ($originalPropertyType instanceof UnionType) { - $currentPropertyType = $nodeScopeResolver->readStoredOrPriceOnDemand($propertyFetch, $scope); + $currentPropertyType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch, $scope); $originalPropertyType = $originalPropertyType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); } diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 55559f12fef..13e768dc3ac 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -84,9 +84,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read // them instead of re-walking via Scope::getType(). - $getType = static fn (Expr $e): Type => $nativeTypesPromoted - ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) - : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); if ($expr instanceof Expr\AssignOp\Coalesce) { // The coalesce is synthetic; price it on demand. The ??= left is stored @@ -195,7 +193,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { - $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $originalScope)->isNull()->yes(); + $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $originalScope)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), $originalScope, @@ -218,7 +216,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $scope)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $scope)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 434dea3bed8..08adfc32688 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -134,7 +134,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); } - return $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); + return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); }; if ($expr instanceof BinaryOp\Smaller) { @@ -335,7 +335,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $scope->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); } - return $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope); + return $nodeScopeResolver->readTypeOfMaybeStored($e, $scope); }; $leftType = $getType($expr->left); $result = (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index b980fb9ed3e..5b554ae7be7 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -103,9 +103,9 @@ private function augmentBooleanOrTruthyWithConditionalHolders( continue; } - $origType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); - $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftTruthyScope); - $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightTruthyScope); + $origType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); + $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d1929b7760e..5835ccde822 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -403,7 +403,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($functionReflection !== null) { // The call's return type, computed from the already-processed argument - // results (resolveReturnType reads them via readStoredOrPriceOnDemand, + // results (resolveReturnType reads them from the stored results, // never re-running processArgs) - asking Scope::getType() for the // FuncCall here would re-enter this handler on demand, as its result is // not stored yet. @@ -943,9 +943,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // Synthetic nodes (call_user_func's inner FuncCall, clone-with's Clone_) // have no captured arg result; they are priced on demand. - return $nativeTypesPromoted - ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $reflectionScope) - : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $reflectionScope); + return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $reflectionScope->doNotTreatPhpDocTypesAsCertain() : $reflectionScope); }; if ($expr->name instanceof Expr) { @@ -1128,7 +1126,7 @@ private function specifyTypesFromCallableCall(NodeScopeResolver $nodeScopeResolv $calleeType = $nameResult !== null ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) - : $nodeScopeResolver->readStoredOrPriceOnDemand($call->name, $scope); + : $nodeScopeResolver->readTypeOfMaybeStored($call->name, $scope); $assertions = null; $parametersAcceptor = null; @@ -1196,7 +1194,7 @@ private function isFuncCallNarrowable(NodeScopeResolver $nodeScopeResolver, Muta $nameType = $nameResult !== null ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $scope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $scope); if (!$nameType->isCallable()->yes()) { return true; } diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 10f6139f15c..3f60c699c3f 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -80,9 +80,9 @@ public function augmentDisjunctionTypes( // the operands were processed during processExpr; read their stored // results on these filtered scopes instead of re-walking via getType(). - $originalType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); - $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftFilteredScope); - $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightFilteredScope); + $originalType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftFilteredScope); + $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightFilteredScope); if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { continue; @@ -149,7 +149,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol continue; } - $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + $scopeType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); $conditionType = TypeCombinator::remove($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -166,7 +166,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol continue; } - $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + $scopeType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); $conditionType = TypeCombinator::intersect($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -223,7 +223,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol } $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $targetScope); + $targetType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $targetScope); $holderType = $holdersFromSureTypes ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 99195644f45..154f5bc6f93 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -189,8 +189,8 @@ public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\ // the operands were processed during processExpr; read their stored results // instead of re-walking via Scope::getType(). - $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()); - $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()); + $leftType = $nodeScopeResolver->readTypeOfMaybeStored($expr->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readTypeOfMaybeStored($expr->right, $scope->toMutatingScope()); $leftBooleanType = $leftType->toBoolean(); if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { @@ -310,7 +310,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope // the operands and their subexpressions were processed during processExpr; // read their stored results instead of re-walking via Scope::getType(). - $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope->toMutatingScope()); + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $scope->toMutatingScope()); $rightType = $getType($rightExpr); @@ -751,8 +751,8 @@ private function findTypeExpressionsFromBinaryOperation(NodeScopeResolver $nodeS { // the operands were processed during processExpr; read their stored results // instead of re-walking via Scope::getType(). - $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->left, $scope->toMutatingScope()); - $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->right, $scope->toMutatingScope()); + $leftType = $nodeScopeResolver->readTypeOfMaybeStored($binaryOperation->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readTypeOfMaybeStored($binaryOperation->right, $scope->toMutatingScope()); $rightExpr = $binaryOperation->right; if ($rightExpr instanceof AlwaysRememberedExpr) { @@ -883,7 +883,7 @@ private function specifyTypesForConstantStringBinaryExpression( ) { // the argument was processed during processExpr; read its stored result // instead of re-walking via Scope::getType(). - $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode->getArgs()[0]->value, $scope->toMutatingScope()); + $argType = $nodeScopeResolver->readTypeOfMaybeStored($exprNode->getArgs()[0]->value, $scope->toMutatingScope()); $objectType = new ObjectType($constantStringValue); $classStringType = new GenericClassStringType($objectType); @@ -928,7 +928,7 @@ private function specifyTypesForConstantStringBinaryExpression( $argValue = $exprNode->getArgs()[0]->value; // the argument was processed during processExpr; read its stored result // instead of re-walking via Scope::getType(). - $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($argValue, $scope->toMutatingScope()); + $argType = $nodeScopeResolver->readTypeOfMaybeStored($argValue, $scope->toMutatingScope()); if ($argType->isString()->yes()) { return $this->defaultNarrowingHelper->createForSubject( $argValue, diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 1c8861e0985..eb37997e56b 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -42,7 +42,7 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver $exprType = $exprResult !== null ? $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); $toStringMethod = null; if (!$exprType->isObject()->no()) { @@ -82,7 +82,7 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver $toStringCall, $scope, ExpressionContext::createDeep(), - $nodeScopeResolver->priceSyntheticOnDemand($toStringCall, $scope), + $nodeScopeResolver->processSyntheticOnDemand($toStringCall, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted), ); if ($throwPoint !== null) { $throwPoints[] = $throwPoint; diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 3545dd35491..d5d5936815a 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -25,7 +25,7 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver // the expression has not been processed into the storage yet (this runs // before processExprNode), so read its type from the stored result or // price it on demand instead of re-walking via Scope::getType(). - $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope); + $exprType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $scope); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -35,9 +35,9 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope); + $originalExprType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $originalScope); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope->doNotTreatPhpDocTypesAsCertain()); + $originalNativeType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $originalScope->doNotTreatPhpDocTypesAsCertain()); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -55,8 +55,8 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope), - $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope->doNotTreatPhpDocTypesAsCertain()), + $nodeScopeResolver->readTypeOfMaybeStored($parentExpr, $scope), + $nodeScopeResolver->readTypeOfMaybeStored($parentExpr, $scope->doNotTreatPhpDocTypesAsCertain()), $originalScope->hasExpressionType($parentExpr), ); } @@ -67,7 +67,7 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $certainty = $hasExpressionType; } - $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope->doNotTreatPhpDocTypesAsCertain()); + $nativeType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $scope->doNotTreatPhpDocTypesAsCertain()); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 149728c9ab7..0e92dafd595 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -99,7 +99,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope); + $varType = $nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } @@ -174,7 +174,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $readType = static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { $result = $chainResults[spl_object_id($e)] ?? null; - return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); + return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readTypeOfMaybeStored($e, $s); }; if (count($expr->vars) === 0 || $context->null()) { diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index c564579e5c0..7ff8a96ff73 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -82,7 +82,7 @@ public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, Mutat $cond = $expr->cond; // the subject was processed before this shadow walk runs; read its stored // result on the incoming scope instead of re-walking via Scope::getType(). - $condType = $nodeScopeResolver->readStoredOrPriceOnDemand($cond, $scope); + $condType = $nodeScopeResolver->readTypeOfMaybeStored($cond, $scope); $armScopesAndTypes = []; $matchScope = $scope; @@ -147,7 +147,7 @@ public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, Mutat // the arm body is read on the subject-narrowed scope this shadow // walk built; that (body, narrowed-scope) pair is not stored, so // price the body on demand against the current storage. - $armScopesAndTypes[] = [$armScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $armScope)]; + $armScopesAndTypes[] = [$armScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $armScope)->getTypeOnScope($armScope, false)]; unset($arms[$i]); } @@ -176,7 +176,7 @@ public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, Mutat if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); } - $armScopesAndTypes[] = [$matchScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $matchScope)]; + $armScopesAndTypes[] = [$matchScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $matchScope)->getTypeOnScope($matchScope, false)]; continue; } @@ -188,14 +188,14 @@ public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, Mutat // the filtering expression is synthetic - price it on demand against the // current storage instead of re-walking via Scope::getType(). - $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); + $filteringExprType = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope)->getTypeOnScope($matchScope, false); if (!$filteringExprType->isFalse()->yes()) { $truthyScope = $matchScope->filterByTruthyValue($filteringExpr); if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); } - $armScopesAndTypes[] = [$truthyScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $truthyScope)]; + $armScopesAndTypes[] = [$truthyScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $truthyScope)->getTypeOnScope($truthyScope, false)]; } $matchScope = $matchScope->filterByFalseyValue($filteringExpr); @@ -441,8 +441,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the `subject === cond` comparison is synthetic - price it on demand // against the current storage instead of re-walking via Scope::getType(). $armCondType = $this->treatPhpDocTypesAsCertain - ? $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope) - : $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain()); + ? $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope)->getTypeOnScope($armCondResultScope, false) + : $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($armCondResultScope, true); if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } @@ -479,7 +479,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // always false is unreachable and does not contribute to the result // type. The filtering expression is synthetic - price it on demand // against the current storage instead of re-walking via Scope::getType(). - $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); + $filteringExprType = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope)->getTypeOnScope($matchScope, false); if (!$filteringExprType->isFalse()->yes()) { $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 76eff55d1f8..4410f7fd4a3 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -241,8 +241,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // The call's return type, computed from the already-processed argument - // results (resolveReturnType reads them via the receiver/name results and - // readStoredOrPriceOnDemand, never re-running processArgs) - asking + // results (resolveReturnType reads them via the receiver/name results, + // never re-running processArgs) - asking // Scope::getType() for the MethodCall here would re-enter this handler on // demand, as its final result is not stored yet. $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); @@ -407,7 +407,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // calls can be less precise. $nameType = $nameResult !== null ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $reflectionScope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $reflectionScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( ...array_map(static function ($constantString) use ($expr, $resolveMethod): Type { diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 3f3df40630a..e5e7b611b54 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -413,7 +413,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // the class expression was processed during processExpr; read its already // computed result instead of re-walking via Scope::getType(). - $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); + $exprType = $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); return $exprType->getObjectTypeOrClassStringObjectType(); } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 678e6125d6e..5e7647fa5e0 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -57,8 +57,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's real (possibly null) type, captured before it is ensured // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. - $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); - $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + $receiverType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); // carry the receiver type to NullsafeMethodCallRule so it reads it from here // instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); @@ -110,9 +110,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); return TypeCombinator::union( - $nativeTypesPromoted - ? $nodeScopeResolver->priceSyntheticOnDemandNative($methodCall, $truthyScope) - : $nodeScopeResolver->priceSyntheticOnDemand($methodCall, $truthyScope), + $nodeScopeResolver->processSyntheticOnDemand($methodCall, $truthyScope)->getTypeOnScope($truthyScope, $nativeTypesPromoted), new NullType(), ); }; diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index dfb01d2d559..87fbff37eb2 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -55,8 +55,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's real (possibly null) type, captured before it is ensured // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. - $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); - $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + $receiverType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); // carry the receiver type to NullsafePropertyFetchRule so it reads it from // here instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); @@ -90,9 +90,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $propertyFetch = new PropertyFetch($expr->var, $expr->name); return TypeCombinator::union( - $nativeTypesPromoted - ? $nodeScopeResolver->priceSyntheticOnDemandNative($propertyFetch, $truthyScope) - : $nodeScopeResolver->priceSyntheticOnDemand($propertyFetch, $truthyScope), + $nodeScopeResolver->processSyntheticOnDemand($propertyFetch, $truthyScope)->getTypeOnScope($truthyScope, $nativeTypesPromoted), new NullType(), ); }; diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 25f7f446ef8..895bf7a9817 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -134,7 +134,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // $obj->{'foo'}-style fetches can be less precise. $nameType = $nameResult !== null ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( ...array_map(static function ($constantString) use ($resolveProperty): Type { diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 1124f959f6d..2fa84572129 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -309,8 +309,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($methodReflection !== null) { // The call's return type, computed from the already-processed argument - // results (resolveReturnType reads them via the class/name results and - // readStoredOrPriceOnDemand, never re-running processArgs) - asking + // results (resolveReturnType reads them via the class/name results, + // never re-running processArgs) - asking // Scope::getType() for the StaticCall here would re-enter this handler on // demand, as its final result is not stored yet. $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); @@ -439,7 +439,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin if ($expr->class instanceof Name) { $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->class, $reflectionScope); + $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope->doNotTreatPhpDocTypesAsCertain()); } $methodReflection = $reflectionScope->getMethodReflection($staticMethodCalledOnType, $methodName); if ($methodReflection === null) { @@ -452,7 +452,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin if ($expr->class instanceof Name) { $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $resolvedClassType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $reflectionScope); + $resolvedClassType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope); $staticMethodCalledOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); } @@ -474,7 +474,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // calls can be less precise. $nameType = $nameResult !== null ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $reflectionScope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $reflectionScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( ...array_map(static function ($constantString) use ($expr, $resolveStaticMethod): Type { @@ -519,7 +519,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop // already-computed result instead of re-walking via Scope::getType(). $calleeType = $classResult !== null ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); } $staticMethodReflection = $scope->getMethodReflection($calleeType, $expr->name->name); @@ -599,7 +599,7 @@ private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?Expre } else { $calleeType = $classResult !== null ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $scope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); } $methodReflection = $scope->getMethodReflection($calleeType, $expr->name->toString()); diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 99a365edb06..86080fbeb5d 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -112,7 +112,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Name) { $staticPropertyFetchedOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $resolvedClassType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->class, $beforeScope); + $resolvedClassType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $beforeScope); $staticPropertyFetchedOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); } @@ -141,7 +141,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // so such fetches can be less precise. $nameType = $nameResult !== null ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) - : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->name, $beforeScope); + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $beforeScope); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( ...array_map(static function ($constantString) use ($resolveProperty): Type { diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 72542829fe3..0ab65d5e884 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType // operand) created inside getUnaryMinusType - priced on demand - return $nativeTypesPromoted ? $nodeScopeResolver->priceSyntheticOnDemandNative($e, $scope) : $nodeScopeResolver->priceSyntheticOnDemand($e, $scope); + return $nodeScopeResolver->processSyntheticOnDemand($e, $scope)->getTypeOnScope($scope, $nativeTypesPromoted); }), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); diff --git a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php index f39d92deab9..37f39ad8b2a 100644 --- a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->getExpr(), $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->getExpr(), $scope), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readTypeOfMaybeStored($expr->getExpr(), $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php index e396fd2f3e3..010a0153021 100644 --- a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->callExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->callExpr, $scope), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readTypeOfMaybeStored($expr->callExpr, $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index eb45922daac..c4fd35bb01b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1073,7 +1073,7 @@ public function processStmtNode( && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection && $scope->getFunction()->getDeclaringClass()->hasConstructor() && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($this->readStoredOrPriceOnDemand($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null + && TypeUtils::findThisType($this->readTypeOfMaybeStored($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null ) { return; } @@ -1707,8 +1707,8 @@ public function processStmtNode( foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { if ($keyVarExpr !== null) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $keyVarExpr); - $dimFetchType = $this->priceSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType); - $dimFetchNativeType = $this->priceSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); + $dimFetchType = $this->processSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType)->getTypeOnScope($scopeWithIterableValueType, false); + $dimFetchNativeType = $this->processSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($scopeWithIterableValueType, true); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the // two describe the same element for a given iteration. If the value var @@ -1730,8 +1730,8 @@ public function processStmtNode( $dimFetchNativeType = $valueVarNativeType; } } - $keyLoopTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); - $keyLoopNativeTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); + $keyLoopTypes[] = $this->readTypeOfMaybeStored($keyVarExpr, $scopeWithIterableValueType); + $keyLoopNativeTypes[] = $this->readTypeOfMaybeStored($keyVarExpr, $scopeWithIterableValueType); } else { // No key variable: the narrowed value var is the array element type // directly. Read it by name (assigned, not processExprNode-processed); @@ -1988,7 +1988,7 @@ public function processStmtNode( $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope) : $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readTypeOfMaybeStored($stmt->cond, $bodyScope) : $this->readTypeOfMaybeStored($stmt->cond, $bodyScope->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -2109,7 +2109,7 @@ public function processStmtNode( $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($this->readStoredOrPriceOnDemand($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); + $alwaysIterates = $alwaysIterates->and($this->readTypeOfMaybeStored($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); } @@ -2489,7 +2489,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $this->readStoredOrPriceOnDemand($var->var, $scope); + $varType = $this->readTypeOfMaybeStored($var->var, $scope); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2881,26 +2881,23 @@ public function processExprOnDemand(Expr $expr, MutatingScope $scope, Expression } } + public function findStoredResult(Expr $expr, MutatingScope $scope): ?ExpressionResult + { + return $scope->getCurrentExpressionResultStorage()?->findExpressionResult($expr); + } + /** - * Reads the type, on the given scope, of a node an ExprHandler already - * processed (its ExpressionResult is in the storage of the analysis in - * progress). Used from lazily-invoked typeCallbacks instead of - * Scope::getType(): it reads the stored result rather than re-walking, and - * does not allocate a throwaway duplicate storage. Falls back to pricing the - * node as synthetic when it is not stored (e.g. a re-evaluation reached this - * before the original processing did). + * The type, on the given scope, of a node that may or may not have a stored + * ExpressionResult. Every call site of this method is UNDECIDED about whether + * the node was already analysed - each should eventually either consume the + * node's ExpressionResult where it was processed or be a synthetic node + * (processSyntheticOnDemand()). */ - public function readStoredOrPriceOnDemand(Expr $expr, MutatingScope $scope): Type + public function readTypeOfMaybeStored(Expr $expr, MutatingScope $scope): Type { - $current = $scope->getCurrentExpressionResultStorage(); - $result = $current?->findExpressionResult($expr); - if ($result !== null) { - return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); - } + $result = $this->findStoredResult($expr, $scope) ?? $this->processSyntheticOnDemand($expr, $scope); - $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); - - return $this->priceSyntheticOnDemand($expr, $scope); + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); } /** @@ -2929,39 +2926,18 @@ private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): vo } /** - * Prices a synthetic node (one an ExprHandler built itself) on a duplicate of - * the storage of the analysis currently in progress, mirroring + * Processes a synthetic node (one an ExprHandler built itself) on a duplicate + * of the storage of the analysis currently in progress, mirroring * MutatingScope::resolveTypeOfNewWorldHandlerNode(): the duplicate isolates * the synthetic node's own stored result from the live storage while its real * subnodes still resolve from the fallback. */ - public function priceSyntheticOnDemand(Expr $expr, MutatingScope $scope): Type + public function processSyntheticOnDemand(Expr $expr, MutatingScope $scope): ExpressionResult { - $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); - - return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeOnScope($scope, $scope->nativeTypesPromoted); - } - - /** Native counterpart of readStoredOrPriceOnDemand(). */ - public function readStoredOrPriceOnDemandNative(Expr $expr, MutatingScope $scope): Type - { - $current = $scope->getCurrentExpressionResultStorage(); - $result = $current?->findExpressionResult($expr); - if ($result !== null) { - return $result->getTypeOnScope($scope, true); - } - $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); - - return $this->priceSyntheticOnDemandNative($expr, $scope); - } - - /** Native counterpart of priceSyntheticOnDemand(). */ - public function priceSyntheticOnDemandNative(Expr $expr, MutatingScope $scope): Type - { $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); - return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeOnScope($scope, true); + return $this->processExprOnDemand($expr, $scope, $current->duplicate()); } /** @@ -3613,7 +3589,7 @@ private function resolveCallableTypeForScope(Expr $expr, MutatingScope $scope): return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr); } - return $this->readStoredOrPriceOnDemand($expr, $scope); + return $this->readTypeOfMaybeStored($expr, $scope); } /** @@ -4407,7 +4383,7 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $this->readStoredOrPriceOnDemand($arg->value, $scope); + $argType = $this->readTypeOfMaybeStored($arg->value, $scope); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { @@ -4552,8 +4528,8 @@ private function selectArgsMetadataAcceptor(array $args, array $gatheredTypes, a $parametersAcceptors, $namedArgumentsVariants, $scope, - fn (Expr $e): Type => $this->readStoredOrPriceOnDemand($e, $scope), - fn (Expr $e): Type => $this->readStoredOrPriceOnDemandNative($e, $scope), + fn (Expr $e): Type => $this->readTypeOfMaybeStored($e, $scope), + fn (Expr $e): Type => $this->readTypeOfMaybeStored($e, $scope->doNotTreatPhpDocTypesAsCertain()), static fn (Type $t): Type => $scope->getIterableValueType($t), static fn (Type $t): Type => $scope->getIterableKeyType($t), ); @@ -4815,14 +4791,14 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $this->priceSyntheticOnDemand($variableNode, $scope->doNotTreatPhpDocTypesAsCertain()), + $this->processSyntheticOnDemand($variableNode, $scope->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($scope, true), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $this->readStoredOrPriceOnDemand($defaultExpr, $scope); + $originalType = $this->readTypeOfMaybeStored($defaultExpr, $scope); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -5234,8 +5210,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $arrayArg = $args[0]->value; $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $this->readStoredOrPriceOnDemand($arrayArg, $scope)->getIterableValueType(), - $this->readStoredOrPriceOnDemand($arrayArg, $scope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readTypeOfMaybeStored($arrayArg, $scope)->getIterableValueType(), + $this->readTypeOfMaybeStored($arrayArg, $scope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -5533,7 +5509,7 @@ public function processCalledMethod(MethodReflection $methodReflection): ?Mutati $statementResult = $executionEnd->getStatementResult(); $endNode = $executionEnd->getNode(); if ($endNode instanceof Node\Stmt\Expression) { - $exprType = $this->readStoredOrPriceOnDemand($endNode->expr, $statementResult->getScope()->toMutatingScope()); + $exprType = $this->readTypeOfMaybeStored($endNode->expr, $statementResult->getScope()->toMutatingScope()); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5920,12 +5896,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope); + $arrayType = $this->readTypeOfMaybeStored($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readTypeOfMaybeStored($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -5945,12 +5921,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope); + $arrayType = $this->readTypeOfMaybeStored($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readTypeOfMaybeStored($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 557f4949469..f3e9313b785 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -43,10 +43,10 @@ public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeReso // typeCallback) so the operands are read from their ExpressionResults // instead of Scope::getType(); rules call this without it (BC). $leftType = $nodeScopeResolver !== null - ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()) + ? $nodeScopeResolver->readTypeOfMaybeStored($expr->left, $scope->toMutatingScope()) : $scope->getType($expr->left); $rightType = $nodeScopeResolver !== null - ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()) + ? $nodeScopeResolver->readTypeOfMaybeStored($expr->right, $scope->toMutatingScope()) : $scope->getType($expr->right); if ( diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index ad213042f35..a0968f6260f 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -227,7 +227,7 @@ public function normalize(Scope $scope, ?NodeScopeResolver $nodeScopeResolver = // $nodeScopeResolver is passed from inside-out callbacks so the expr // type is read from its ExpressionResult instead of Scope::getType(). $exprType = $nodeScopeResolver !== null - ? $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode, $scope->toMutatingScope()) + ? $nodeScopeResolver->readTypeOfMaybeStored($exprNode, $scope->toMutatingScope()) : $scope->getType($exprNode); $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($exprType, $sureNotType)]; continue; From 0b22f948133782a9dd420466f7ffe05d2faa90b0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 18:18:14 +0200 Subject: [PATCH 230/378] Reintroduce readStoredResult as a storage-based assertion readStoredResult(Expr, ExpressionResultStorage) reads the stored result of a node the caller processed into the very storage it holds, throwing when it is absent - a decisive claim about processing order that a scope-based lookup cannot make: loop-convergence passes process into local duplicate storages that are never scope-visible, so a scope-based read there silently missed and re-priced. (Pushing the convergence duplicates onto the scope's storage stack was tried instead and rejected: convergence reads then see position-time stored results where the miss-path re-priced on the merged/generalized scope, losing benevolent-union widening - see nsrt/myers-diff-loop-widening.php.) Sites that hold their processing storage now assert stored-ness instead of calling readTypeOfMaybeStored: the Unset_ target chain read, the impure-arg read in processArgs, enterForeach's array_keys rewrite, inferForLoopExpressions (storage threaded in), the isset subject chain read, the Div/Mod throw-point check, and getArmScopesAndTypes' subject read (storage threaded through AssignHandler). --- src/Analyser/ExprHandler/AssignHandler.php | 5 ++- src/Analyser/ExprHandler/AssignOpHandler.php | 2 +- src/Analyser/ExprHandler/IssetHandler.php | 2 +- src/Analyser/ExprHandler/MatchHandler.php | 4 +- src/Analyser/NodeScopeResolver.php | 41 +++++++++++++++----- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 25a91f1537d..0d949fa6bb3 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -502,7 +502,7 @@ public function processAssignVar( if ($assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, - $this->processMatchForConditionalExpressionsAfterAssign($nodeScopeResolver, $scopeBeforeAssignEval, $var->name, $assignedExpr), + $this->processMatchForConditionalExpressionsAfterAssign($nodeScopeResolver, $scopeBeforeAssignEval, $storage, $var->name, $assignedExpr), ); } @@ -1315,11 +1315,12 @@ private function mergeConditionalExpressions(array $conditionalExpressions, arra private function processMatchForConditionalExpressionsAfterAssign( NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, + ExpressionResultStorage $storage, string $variableName, Match_ $expr, ): array { - $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($nodeScopeResolver, $scope, $expr); + $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($nodeScopeResolver, $scope, $storage, $expr); if (count($armScopesAndTypes) < 2) { return []; } diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 13e768dc3ac..ae8156f9f5f 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -216,7 +216,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $scope)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$nodeScopeResolver->readStoredResult($expr->expr, $storage)->getTypeOnScope($scope, false)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 0e92dafd595..9e95858f9ec 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -99,7 +99,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope); + $varType = $nodeScopeResolver->readStoredResult($var->var, $storage)->getTypeOnScope($scope, false); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 7ff8a96ff73..3d0beeeb69a 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -77,12 +77,12 @@ public function supports(Expr $expr): bool * * @return list */ - public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Match_ $expr): array + public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Match_ $expr): array { $cond = $expr->cond; // the subject was processed before this shadow walk runs; read its stored // result on the incoming scope instead of re-walking via Scope::getType(). - $condType = $nodeScopeResolver->readTypeOfMaybeStored($cond, $scope); + $condType = $nodeScopeResolver->readStoredResult($cond, $storage)->getTypeOnScope($scope, false); $armScopesAndTypes = []; $matchScope = $scope; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c4fd35bb01b..7c469766843 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2111,7 +2111,7 @@ public function processStmtNode( if ($lastCondExpr !== null) { $alwaysIterates = $alwaysIterates->and($this->readTypeOfMaybeStored($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); - $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); + $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope, $storage); } $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -2489,7 +2489,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $this->readTypeOfMaybeStored($var->var, $scope); + $varType = $this->readStoredResult($var->var, $storage)->getTypeOnScope($scope, false); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2886,6 +2886,27 @@ public function findStoredResult(Expr $expr, MutatingScope $scope): ?ExpressionR return $scope->getCurrentExpressionResultStorage()?->findExpressionResult($expr); } + /** + * The stored ExpressionResult of a node processExprNode() already processed + * into the given storage - the caller asserts the processing order by + * holding the very storage it processed the node into (a scope-based lookup + * would miss loop-convergence storages, which are never scope-visible). + * Throws when the node has no stored result. + */ + public function readStoredResult(Expr $expr, ExpressionResultStorage $storage): ExpressionResult + { + $result = $storage->findExpressionResult($expr); + if ($result === null) { + throw new ShouldNotHappenException(sprintf( + '%s on line %d has no stored ExpressionResult - it was not processed by processExprNode().', + get_class($expr), + $expr->getStartLine(), + )); + } + + return $result; + } + /** * The type, on the given scope, of a node that may or may not have a stored * ExpressionResult. Every call site of this method is UNDECIDED about whether @@ -4383,7 +4404,7 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $this->readTypeOfMaybeStored($arg->value, $scope); + $argType = $this->readStoredResult($arg->value, $storage)->getTypeOnScope($scope, false); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { @@ -5210,8 +5231,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $arrayArg = $args[0]->value; $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $this->readTypeOfMaybeStored($arrayArg, $scope)->getIterableValueType(), - $this->readTypeOfMaybeStored($arrayArg, $scope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($scope, false)->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($scope, true)->getIterableValueType(), ); } } @@ -5865,7 +5886,7 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): return $stmts; } - private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope, ExpressionResultStorage $storage): MutatingScope { // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} @@ -5896,12 +5917,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $this->readTypeOfMaybeStored($arrayArg, $bodyScope); + $arrayType = $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, false); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $this->readTypeOfMaybeStored($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, true)->getIterableValueType(), ); } } @@ -5921,12 +5942,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $this->readTypeOfMaybeStored($arrayArg, $bodyScope); + $arrayType = $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, false); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $this->readTypeOfMaybeStored($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, true)->getIterableValueType(), ); } } From a190af44caaf95ca73627e410a1016e2e405289a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 18:35:33 +0200 Subject: [PATCH 231/378] Narrow ?? by composing isset facts from chain results, not a synthetic Isset_ The truthy chain narrowing of isset() (per-link HasOffset/NonEmptyArray/ HasProperty facts plus not-null for every link) moves from IssetHandler's specifyTypesCallback into DefaultNarrowingHelper, together with the chain-result capture and the chain-type reader. CoalesceHandler composes its left-is-set narrowing from the already-processed chain results instead of synthesizing an Isset_ node and re-walking the whole left chain on demand - twice eagerly per ?? and once per flavour in the type callback. The left side is still re-processed once on the narrowed scope inside the type callback: offsets legitimately resolve against the HasOffset-narrowed parent there. IssetHandler's last TypeSpecifier delegation (specifyDefaultTypes for the empty/null-context case) goes through DefaultNarrowingHelper too, dropping the TypeSpecifier dependency. --- phpstan-baseline.neon | 8 +- src/Analyser/ExprHandler/CoalesceHandler.php | 43 ++++- .../Helper/DefaultNarrowingHelper.php | 178 ++++++++++++++++++ src/Analyser/ExprHandler/IssetHandler.php | 136 +------------ 4 files changed, 222 insertions(+), 143 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3e2cb044d6e..34955d177cc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -51,7 +51,13 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 1 + path: src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php + + - + rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' + identifier: phpstanApi.instanceofType + count: 1 path: src/Analyser/ExprHandler/IssetHandler.php - diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 15996fb19e7..3b1a4254293 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -76,11 +76,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // mid-processing would take the on-demand path and recurse $rightScope = $scope->applySpecifiedTypes($this->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); + // the left-is-set narrowing, composed from the already-processed chain + // results - the inside-out equivalent of narrowing by isset($expr->left) + // without synthesizing an Isset_ node and re-walking the chain on demand + $chainResults = []; + $this->defaultNarrowingHelper->captureChainResults($expr->left, $storage, $chainResults); + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $scope, + $expr->left, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope, $nodeScopeResolver), + $expr, + TypeSpecifierContext::createTruthy(), + ); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); + $scope = $scope->applySpecifiedTypes($leftIssetTypes); } else { - $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy()))->mergeWith($rightResult->getScope()); + $scope = $scope->applySpecifiedTypes($leftIssetTypes)->mergeWith($rightResult->getScope()); } $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??'), $beforeScope, $storage, $context); @@ -93,9 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { - $issetLeftExpr = new Expr\Isset_([$expr->left]); - + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope, $chainResults): Type { // the isset resolution and the left-is-set narrowing run on // beforeScope (the evaluation point), not the asking scope. $result = $condResult->getIssetabilityResolution($beforeScope, false)->isSet(static function (Type $type): ?bool { @@ -107,8 +118,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return !$isNull->yes(); }); + // the left side's type when it is set: the left re-processed on the + // left-is-set narrowed scope (a genuinely different scope than the + // left's own - offsets resolve against the HasOffset-narrowed parent) + $leftIsSetType = function () use ($expr, $nodeScopeResolver, $beforeScope, $chainResults): Type { + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $beforeScope, + $expr->left, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $beforeScope, $nodeScopeResolver), + $expr, + TypeSpecifierContext::createTruthy(), + ); + + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($leftIssetTypes), new ExpressionResultStorage())->getType()); + }; + if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()); + return $leftIsSetType(); } // the right side was processed on the left-is-null scope, so its own @@ -116,10 +142,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); if ($result === null) { - return TypeCombinator::union( - TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()), - $rightType, - ); + return TypeCombinator::union($leftIsSetType(), $rightType); } return $rightType; diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 24e6ef3bf27..0b8ccdaa241 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -4,16 +4,36 @@ use Closure; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticPropertyFetch; +use PhpParser\Node\Identifier; +use PhpParser\Node\VarLikeIdentifier; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; +use PHPStan\Type\Accessory\HasOffsetType; +use PHPStan\Type\Accessory\HasPropertyType; +use PHPStan\Type\Accessory\NonEmptyArrayType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; +use function array_reverse; +use function is_string; +use function spl_object_id; /** * New-world replacement for TypeSpecifier::handleDefaultTruthyOrFalseyContext(): @@ -130,4 +150,162 @@ public function createForSubject(Expr $subject, Type $type, TypeSpecifierContext ); } + + /** + * Captures the stored ExpressionResults of an isset/empty/?? subject's + * chain links (the results, not the storage - no reference cycle) so + * narrowing callbacks read their types instead of re-walking the chain. + * + * @param array $chainResults + */ + public function captureChainResults(Expr $node, ExpressionResultStorage $storage, array &$chainResults): void + { + $result = $storage->findExpressionResult($node); + if ($result !== null) { + $chainResults[spl_object_id($node)] = $result; + } + + if ($node instanceof ArrayDimFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + if ($node->dim !== null) { + $this->captureChainResults($node->dim, $storage, $chainResults); + } + } elseif ($node instanceof PropertyFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + } elseif ($node instanceof StaticPropertyFetch && $node->class instanceof Expr) { + $this->captureChainResults($node->class, $storage, $chainResults); + } + } + + /** + * The chain-link type reader for the captured results: an already-processed + * link resolves through its result on the asking scope (honouring narrowing), + * anything else through the maybe-stored fallback. + * + * @param array $chainResults + * @return Closure(Expr): Type + */ + public function buildChainTypeReader(array $chainResults, MutatingScope $s, NodeScopeResolver $nodeScopeResolver): Closure + { + return static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { + $result = $chainResults[spl_object_id($e)] ?? null; + + return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readTypeOfMaybeStored($e, $s); + }; + } + + /** + * The truthy narrowing of isset($issetExpr), composed from the subject's + * chain: per-link HasOffset/NonEmptyArray/HasProperty facts plus a not-null + * entry for every link - exactly what the Isset_ handler emits in the true + * context. Lets ?? narrow its left side without synthesizing an Isset_ node + * and re-walking the chain on demand. + * + * @param Closure(Expr): Type $readType + */ + public function createIssetTruthyChainTypes(MutatingScope $s, Expr $issetExpr, Closure $readType, Expr $rootExpr, TypeSpecifierContext $context): SpecifiedTypes + { + $tmpVars = [$issetExpr]; + while ( + $issetExpr instanceof ArrayDimFetch + || $issetExpr instanceof PropertyFetch + || ( + $issetExpr instanceof StaticPropertyFetch + && $issetExpr->class instanceof Expr + ) + ) { + if ($issetExpr instanceof StaticPropertyFetch) { + /** @var Expr $issetExpr */ + $issetExpr = $issetExpr->class; + } else { + $issetExpr = $issetExpr->var; + } + $tmpVars[] = $issetExpr; + } + $vars = array_reverse($tmpVars); + + $types = new SpecifiedTypes(); + foreach ($vars as $var) { + + if ($var instanceof Expr\Variable && is_string($var->name)) { + if ($s->hasVariableType($var->name)->no()) { + return (new SpecifiedTypes([], []))->setRootExpr($rootExpr); + } + } + + if ( + $var instanceof ArrayDimFetch + && $var->dim !== null + && !$readType($var->var) instanceof MixedType + ) { + $dimType = $readType($var->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $types = $types->unionWith( + $this->createForSubject( + $var->var, + new HasOffsetType($dimType), + $context, + $s, + )->setRootExpr($rootExpr), + ); + } else { + $varType = $readType($var->var); + + $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); + if ($narrowedKey !== null) { + $types = $types->unionWith( + $this->createForSubject( + $var->dim, + $narrowedKey, + $context, + $s, + )->setRootExpr($rootExpr), + ); + } + + if ($varType->isArray()->yes()) { + $types = $types->unionWith( + $this->createForSubject( + $var->var, + new NonEmptyArrayType(), + $context, + $s, + )->setRootExpr($rootExpr), + ); + } + } + } + + if ( + $var instanceof PropertyFetch + && $var->name instanceof Identifier + ) { + $types = $types->unionWith( + $this->createForSubject($var->var, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($rootExpr), + ); + } elseif ( + $var instanceof StaticPropertyFetch + && $var->class instanceof Expr + && $var->name instanceof VarLikeIdentifier + ) { + $types = $types->unionWith( + $this->createForSubject($var->class, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($rootExpr), + ); + } + + $types = $types->unionWith( + $this->createForSubject($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($rootExpr), + ); + } + + return $types; + } + } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 9e95858f9ec..6e6ed0e1a8a 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -24,7 +24,6 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; @@ -63,7 +62,6 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -126,7 +124,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // getTypeOnScope() instead of re-walking through Scope::getType(). $chainResults = []; foreach ($expr->vars as $var) { - $this->captureChainResults($var, $storage, $chainResults); + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $chainResults); } $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); @@ -171,14 +169,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // type of an already-processed chain link, read from its captured // result (re-evaluated on the asking scope, honouring narrowing) - // never re-walked through the scope - $readType = static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { - $result = $chainResults[spl_object_id($e)] ?? null; - - return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readTypeOfMaybeStored($e, $s); - }; + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $s, $nodeScopeResolver); if (count($expr->vars) === 0 || $context->null()) { - return $this->typeSpecifier->specifyDefaultTypes($s, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } // rewrite multi param isset() to and-chained single param isset() @@ -312,131 +306,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new SpecifiedTypes(); } - $tmpVars = [$issetExpr]; - while ( - $issetExpr instanceof ArrayDimFetch - || $issetExpr instanceof PropertyFetch - || ( - $issetExpr instanceof StaticPropertyFetch - && $issetExpr->class instanceof Expr - ) - ) { - if ($issetExpr instanceof StaticPropertyFetch) { - /** @var Expr $issetExpr */ - $issetExpr = $issetExpr->class; - } else { - $issetExpr = $issetExpr->var; - } - $tmpVars[] = $issetExpr; - } - $vars = array_reverse($tmpVars); - - $types = new SpecifiedTypes(); - foreach ($vars as $var) { - - if ($var instanceof Expr\Variable && is_string($var->name)) { - if ($s->hasVariableType($var->name)->no()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - if ( - $var instanceof ArrayDimFetch - && $var->dim !== null - && !$readType($var->var) instanceof MixedType - ) { - $dimType = $readType($var->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject( - $var->var, - new HasOffsetType($dimType), - $context, - $s, - )->setRootExpr($expr), - ); - } else { - $varType = $readType($var->var); - - $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); - if ($narrowedKey !== null) { - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject( - $var->dim, - $narrowedKey, - $context, - $s, - )->setRootExpr($expr), - ); - } - - if ($varType->isArray()->yes()) { - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject( - $var->var, - new NonEmptyArrayType(), - $context, - $s, - )->setRootExpr($expr), - ); - } - } - } - - if ( - $var instanceof PropertyFetch - && $var->name instanceof Identifier - ) { - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject($var->var, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), - ); - } elseif ( - $var instanceof StaticPropertyFetch - && $var->class instanceof Expr - && $var->name instanceof VarLikeIdentifier - ) { - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject($var->class, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), - ); - } - - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($expr), - ); - } - - return $types; + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $issetExpr, $readType, $expr, $context); }, ); } - /** - * @param array $chainResults - */ - private function captureChainResults(Expr $node, ExpressionResultStorage $storage, array &$chainResults): void - { - $result = $storage->findExpressionResult($node); - if ($result !== null) { - $chainResults[spl_object_id($node)] = $result; - } - - if ($node instanceof ArrayDimFetch) { - $this->captureChainResults($node->var, $storage, $chainResults); - if ($node->dim !== null) { - $this->captureChainResults($node->dim, $storage, $chainResults); - } - } elseif ($node instanceof PropertyFetch) { - $this->captureChainResults($node->var, $storage, $chainResults); - } elseif ($node instanceof StaticPropertyFetch && $node->class instanceof Expr) { - $this->captureChainResults($node->class, $storage, $chainResults); - } - } - } From c0efb63bf6e28629310698ce6e41844958dcbb27 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 18:50:22 +0200 Subject: [PATCH 232/378] Compose multi-subject isset() truthy narrowing from chain results isset($a, $b) is true only when every subject is set, so its truthy narrowing is the union of each subject's own truthy chain narrowing - composed directly from the captured chain results instead of building an and-chain of synthetic single-subject Isset_ nodes and walking each on demand. Non-true contexts (only some subject is unset) keep the and-chain rewrite. --- src/Analyser/ExprHandler/IssetHandler.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 6e6ed0e1a8a..7dee17c5a9e 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -175,8 +175,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - // rewrite multi param isset() to and-chained single param isset() if (count($expr->vars) > 1) { + // isset($a, $b) is true only when every subject is set - the + // truthy narrowing is the union of each subject's own truthy + // chain narrowing, composed directly from the captured results + if ($context->true()) { + $types = new SpecifiedTypes(); + foreach ($expr->vars as $var) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $var, $readType, $expr, $context), + ); + } + + return $types->setRootExpr($expr); + } + + // non-true contexts (only SOME subject is unset) keep the + // and-chained single-param rewrite $issets = []; foreach ($expr->vars as $var) { $issets[] = new Isset_([$var], $expr->getAttributes()); From 3e8eff23bfefecc751f406bf7f174440bae58c20 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 19:12:10 +0200 Subject: [PATCH 233/378] Consume the match arms' captured scope/type pairs instead of re-walking them MatchHandler::processExpr already captures, for each reachable arm, the body's ExpressionResult together with the subject-narrowed scope it was processed on. Expose those pairs (WeakMap-keyed by the match node) and let AssignHandler's conditional-expressions-after-assign projection consume them, deleting getArmScopesAndTypes - the shadow walk that re-narrowed every arm from scratch and re-processed every reachable arm body on demand, walking the arms of an assigned match twice. --- src/Analyser/ExprHandler/AssignHandler.php | 6 +- src/Analyser/ExprHandler/MatchHandler.php | 146 +++------------------ 2 files changed, 25 insertions(+), 127 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 0d949fa6bb3..79192129b45 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1320,8 +1320,10 @@ private function processMatchForConditionalExpressionsAfterAssign( Match_ $expr, ): array { - $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($nodeScopeResolver, $scope, $storage, $expr); - if (count($armScopesAndTypes) < 2) { + // the pairs were captured while the match (the assigned expression) was + // processed just above - no arm re-walk + $armScopesAndTypes = $this->matchHandler->getCapturedArmScopesAndTypes($expr); + if ($armScopesAndTypes === null || count($armScopesAndTypes) < 2) { return []; } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 3d0beeeb69a..ef0118f5896 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -40,6 +40,7 @@ use PHPStan\Type\UnionType; use UnhandledMatchError; use function array_key_exists; +use WeakMap; use function array_merge; use function array_values; use function count; @@ -54,6 +55,9 @@ final class MatchHandler implements ExprHandler { + /** @var WeakMap> */ + private WeakMap $capturedArmResults; + public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, @@ -61,6 +65,7 @@ public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, ) { + $this->capturedArmResults = new WeakMap(); } public function supports(Expr $expr): bool @@ -69,139 +74,28 @@ public function supports(Expr $expr): bool } /** - * For each reachable match arm, returns the arm's body type together with the - * scope in which the match subject is narrowed to that arm's condition. This - * lets callers reconstruct the relationship between the match result and the + * For each reachable arm of an already-processed match, the arm's body type + * together with the scope in which the subject is narrowed to that arm's + * condition - the pairs captured during processExpr()'s single walk. Lets + * callers reconstruct the relationship between the match result and the * narrowed subject (e.g. to project a later narrowing of the assigned result - * back onto the subject). + * back onto the subject) without re-walking the arms. Null when the node was + * never processed. * - * @return list + * @return list|null */ - public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Match_ $expr): array + public function getCapturedArmScopesAndTypes(Match_ $expr): ?array { - $cond = $expr->cond; - // the subject was processed before this shadow walk runs; read its stored - // result on the incoming scope instead of re-walking via Scope::getType(). - $condType = $nodeScopeResolver->readStoredResult($cond, $storage)->getTypeOnScope($scope, false); - $armScopesAndTypes = []; - - $matchScope = $scope; - $arms = $expr->arms; - if ($condType->isEnum()->yes()) { - // enum match analysis would work even without this if branch - // but would be much slower - // this avoids using ObjectType::$subtractedType which is slow for huge enums - // because of repeated union type normalization - $enumCases = $condType->getEnumCases(); - if (count($enumCases) > 0) { - $indexedEnumCases = []; - foreach ($enumCases as $enumCase) { - $indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase; - } - $unusedIndexedEnumCases = $indexedEnumCases; - - foreach ($arms as $i => $arm) { - if ($arm->conds === null) { - continue; - } - - $conditionCases = []; - foreach ($arm->conds as $armCond) { - if (!$armCond instanceof Expr\ClassConstFetch) { - continue 2; - } - if (!$armCond->class instanceof Name) { - continue 2; - } - if (!$armCond->name instanceof Identifier) { - continue 2; - } - $fetchedClassName = $scope->resolveName($armCond->class); - $loweredFetchedClassName = strtolower($fetchedClassName); - if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) { - continue 2; - } - - $caseName = $armCond->name->toString(); - if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) { - continue 2; - } - - $conditionCases[] = $indexedEnumCases[$loweredFetchedClassName][$caseName]; - unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]); - } - - $conditionCasesCount = count($conditionCases); - if ($conditionCasesCount === 0) { - throw new ShouldNotHappenException(); - } elseif ($conditionCasesCount === 1) { - $conditionCaseType = $conditionCases[0]; - } else { - $conditionCaseType = new UnionType($conditionCases); - } - - $armScope = $matchScope->addTypeToExpression( - $cond, - $conditionCaseType, - ); - // the arm body is read on the subject-narrowed scope this shadow - // walk built; that (body, narrowed-scope) pair is not stored, so - // price the body on demand against the current storage. - $armScopesAndTypes[] = [$armScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $armScope)->getTypeOnScope($armScope, false)]; - unset($arms[$i]); - } - - $remainingCases = []; - foreach ($unusedIndexedEnumCases as $cases) { - foreach ($cases as $case) { - $remainingCases[] = $case; - } - } - - $remainingCasesCount = count($remainingCases); - if ($remainingCasesCount === 0) { - $remainingType = new NeverType(); - } elseif ($remainingCasesCount === 1) { - $remainingType = $remainingCases[0]; - } else { - $remainingType = new UnionType($remainingCases); - } - - $matchScope = $matchScope->addTypeToExpression($cond, $remainingType); - } + if (!isset($this->capturedArmResults[$expr])) { + return null; } - foreach ($arms as $arm) { - if ($arm->conds === null) { - if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { - $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); - } - $armScopesAndTypes[] = [$matchScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $matchScope)->getTypeOnScope($matchScope, false)]; - continue; - } - - if (count($arm->conds) === 0) { - throw new ShouldNotHappenException(); - } - - $filteringExpr = $this->getFilteringExprForMatchArm($expr, $arm->conds); - - // the filtering expression is synthetic - price it on demand against the - // current storage instead of re-walking via Scope::getType(). - $filteringExprType = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope)->getTypeOnScope($matchScope, false); - - if (!$filteringExprType->isFalse()->yes()) { - $truthyScope = $matchScope->filterByTruthyValue($filteringExpr); - if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { - $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); - } - $armScopesAndTypes[] = [$truthyScope, $nodeScopeResolver->processSyntheticOnDemand($arm->body, $truthyScope)->getTypeOnScope($truthyScope, false)]; - } - - $matchScope = $matchScope->filterByFalseyValue($filteringExpr); + $pairs = []; + foreach ($this->capturedArmResults[$expr] as [$armResult, $bodyScope]) { + $pairs[] = [$bodyScope, $armResult->getType()]; } - return $armScopesAndTypes; + return $pairs; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -530,6 +424,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->cond = $expr->cond->getExpr(); } + $this->capturedArmResults[$expr] = $armTypeResults; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, From 29c585320399c036c08557813639d8ed3e0201f1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 22:01:49 +0200 Subject: [PATCH 234/378] Answer plain variable reads from scope state instead of on-demand processing A string-named variable's type IS scope state - readTypeOfMaybeStored() now reads it directly (mirroring VariableHandler's typeCallback) instead of processing the node on demand against a duplicate storage. Site enumeration over the guarded suite showed pre-processing variable reads were over half of all on-demand fallbacks: NonNullabilityHelper's chain priming before isset/??/nullsafe subjects are processed and the nullsafe handlers' receiver captures dominate. --- src/Analyser/NodeScopeResolver.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7c469766843..38255514eab 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -148,6 +148,7 @@ use PHPStan\Type\ClosureType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\ErrorType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -2916,9 +2917,22 @@ public function readStoredResult(Expr $expr, ExpressionResultStorage $storage): */ public function readTypeOfMaybeStored(Expr $expr, MutatingScope $scope): Type { - $result = $this->findStoredResult($expr, $scope) ?? $this->processSyntheticOnDemand($expr, $scope); + $result = $this->findStoredResult($expr, $scope); + if ($result !== null) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + // a plain variable read is scope state - answer it directly instead of + // processing the node on demand (mirrors VariableHandler's typeCallback) + if ($expr instanceof Expr\Variable && is_string($expr->name)) { + if ($scope->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } + + return $scope->getVariableType($expr->name); + } - return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + return $this->processSyntheticOnDemand($expr, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted); } /** From 01cfe0747ad1a52a5891c76c633a37e3841141d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 22:24:59 +0200 Subject: [PATCH 235/378] Run AnalyserTest through FiberNodeScopeResolver like the other test harnesses AnalyserTest hand-built a plain NodeScopeResolver, so its node callbacks received the raw MutatingScope - FileAnalyserCallback's dependency resolution then asked getType() of not-yet-processed call nodes and paid an on-demand re-walk, polluting PHPSTAN_GUARD_NW enumerations with a harness-only violation class. Production and RuleTestCase/ TypeInferenceTestCase use the fiber resolver, whose FiberScope suspends such asks until the result is stored. --- tests/PHPStan/Analyser/AnalyserTest.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index bd696ce7f7f..8bbd2caec6b 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -34,12 +34,14 @@ use function array_merge; use function assert; use function count; +use function getenv; use function is_string; use function sprintf; use function str_replace; use function strtoupper; use function substr; use const PHP_OS; +use const PHP_VERSION_ID; class AnalyserTest extends PHPStanTestCase { @@ -810,7 +812,12 @@ private function createAnalyser(): Analyser $fileTypeMapper = $container->getByType(FileTypeMapper::class); $phpDocInheritanceResolver = new PhpDocInheritanceResolver($fileTypeMapper); - $nodeScopeResolver = new NodeScopeResolver( + $nodeScopeResolverClassName = NodeScopeResolver::class; + if (PHP_VERSION_ID >= 80100 && getenv('PHPSTAN_FNSR') !== '0') { + $nodeScopeResolverClassName = Fiber\FiberNodeScopeResolver::class; + } + + $nodeScopeResolver = new $nodeScopeResolverClassName( $container, $reflectionProvider, $container->getByType(InitializerExprTypeResolver::class), From a5504955f46f58a2f666e15f00a93d7bf595450e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 22:44:12 +0200 Subject: [PATCH 236/378] Resolve the offset-write property holder without re-reading the receiver getOriginalPropertyType() runs while the assignment target chain ($this->prop['x'] = ...) is still unprocessed, so findPropertyReflectionFromNode()'s Scope::getType() on the receiver paid an on-demand walk. The new PropertyReflectionFinder::findPropertyReflectionFromNodeWithHolderType() takes the holder type the caller already resolved - read maybe-stored, where a plain variable receiver like $this answers from scope state without any walk. --- src/Analyser/ExprHandler/AssignHandler.php | 12 +++++++- .../Properties/PropertyReflectionFinder.php | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 79192129b45..5651cad0085 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1723,7 +1723,17 @@ private function isSameVariable(Expr $a, Expr $b): bool */ private function getOriginalPropertyType(NodeScopeResolver $nodeScopeResolver, PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); + // the fetch is a write target inside an offset chain - nothing of it is + // processed yet, so the holder type is read maybe-stored (a plain variable + // receiver like $this answers from scope state without a walk) + if ($propertyFetch instanceof PropertyFetch) { + $propertyHolderType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch->var, $scope); + } elseif ($propertyFetch->class instanceof Name) { + $propertyHolderType = $scope->resolveTypeByName($propertyFetch->class); + } else { + $propertyHolderType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch->class, $scope); + } + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNodeWithHolderType($propertyFetch, $propertyHolderType, $scope); $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); if ($originalPropertyType instanceof UnionType) { $currentPropertyType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch, $scope); diff --git a/src/Rules/Properties/PropertyReflectionFinder.php b/src/Rules/Properties/PropertyReflectionFinder.php index b25682687b0..a585c613f53 100644 --- a/src/Rules/Properties/PropertyReflectionFinder.php +++ b/src/Rules/Properties/PropertyReflectionFinder.php @@ -83,6 +83,36 @@ public function findPropertyReflectionsFromNode($propertyFetch, Scope $scope): a return $reflections; } + /** + * Variant of findPropertyReflectionFromNode() for callers that already hold + * the property holder's type (e.g. from an ExpressionResult) - the receiver + * is not re-read through Scope::getType(). + * + * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch + */ + public function findPropertyReflectionFromNodeWithHolderType($propertyFetch, Type $propertyHolderType, Scope $scope): ?FoundPropertyReflection + { + if ($propertyFetch instanceof Node\Expr\PropertyFetch) { + if ($propertyFetch->name instanceof Node\Identifier) { + return $this->findInstancePropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + + $nameType = $scope->getType($propertyFetch->name); + $nameTypeConstantStrings = $nameType->getConstantStrings(); + if (count($nameTypeConstantStrings) === 1) { + return $this->findInstancePropertyReflection($propertyHolderType, $nameTypeConstantStrings[0]->getValue(), $scope); + } + + return null; + } + + if (!$propertyFetch->name instanceof Node\Identifier) { + return null; + } + + return $this->findStaticPropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + /** * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch */ From ef0db15bcbe17d0bea2fe1f9e3365fac208c6570 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 2 Jul 2026 23:06:41 +0200 Subject: [PATCH 237/378] Answer maybe-stored reads from a tracked whole-expression holder without a walk When the asking scope tracks a narrowed or ensured type for the whole expression, the on-demand processing would return that very holder anyway - the fresh result's beforeScope is the asking scope, so its getType() takes the tracked-holder branch - after paying the walk. Read the holder directly instead, mirroring the early return in MutatingScope::resolveType() (same Variable/Closure/ArrowFunction exclusions). Covers the pre-processing chain reads of already-narrowed isset/??/nullsafe subjects (NonNullabilityHelper and friends). --- src/Analyser/NodeScopeResolver.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 38255514eab..3c81d719c4d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2932,6 +2932,18 @@ public function readTypeOfMaybeStored(Expr $expr, MutatingScope $scope): Type return $scope->getVariableType($expr->name); } + // a type tracked for the whole expression answers without a walk - the + // on-demand processing below would return this very holder anyway (the + // fresh result's beforeScope is the asking scope), after paying the walk + if ( + !$expr instanceof Expr\Variable + && !$expr instanceof Expr\Closure + && !$expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($expr)->yes() + ) { + return $scope->getTrackedExpressionType($expr); + } + return $this->processSyntheticOnDemand($expr, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted); } From f22bab9dc4405738458b6e6347f661a2b579aa53 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 3 Jul 2026 12:28:31 +0200 Subject: [PATCH 238/378] Skip the null-containment probe for bare variable narrowing subjects createForExpr()'s containsNull probe exists only to decide the nullsafe-shortcircuit unwrap and the createNullsafeTypes() union - both are no-ops for a bare variable (the unwrap is identity and the nullsafe recursion yields nothing), so the probe and its Scope::getType() ask are skipped for one. Narrowing a variable is the most common create() call of all; for an unprocessed subject (an assignment target constrained through the assign's createTypesCallback) the ask was a full on-demand walk. --- src/Analyser/TypeSpecifier.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9b4d0982678..9e1ac73d6a1 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -544,10 +544,15 @@ private function createForExpr( Scope $scope, ): SpecifiedTypes { - if ($context->true()) { - $containsNull = !$type->isNull()->no() && !$scope->getType($expr)->isNull()->no(); - } elseif ($context->false()) { - $containsNull = !TypeCombinator::containsNull($type) && !$scope->getType($expr)->isNull()->no(); + // the null-containment probe only feeds the nullsafe-shortcircuit unwrap + // and createNullsafeTypes() - both are no-ops for a bare variable, so the + // probe (and its type ask) is skipped for one + if (!$expr instanceof Expr\Variable) { + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$scope->getType($expr)->isNull()->no(); + } elseif ($context->false()) { + $containsNull = !TypeCombinator::containsNull($type) && !$scope->getType($expr)->isNull()->no(); + } } $originalExpr = $expr; From d29fd661d1470becbbf4de71d4aa77cfe6a0ce39 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 3 Jul 2026 13:03:35 +0200 Subject: [PATCH 239/378] Pin the dropped-self-condition complement type at holder-build time The complement union in processBooleanConditionalTypes() re-asked Scope::getType() for the holder expression when the holder fired - a read whose value depends on which ExpressionResultStorage happens to be current at ask time. It only worked because the old-world TypeSpecifier::createForExpr() null-containment probe had warmed the scope's resolvedTypes cache with the right value at narrowing time (regressed bug-14874's null the moment that probe disappeared). The condition set already reads the same (scope, expression) value when it is built - carry it alongside the conditions and compute the complement from it, making the holder timing-independent. --- .../Helper/ConditionalExpressionHolderHelper.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 3f60c699c3f..8e843e033ce 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -144,6 +144,10 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol // let the resulting holder fire too eagerly. $conditionExpressionTypes = []; $droppedNoOpConditions = []; + // the unnarrowed type of each condition expression, pinned at + // holder-build time: the dropped-self-condition complement below must + // not re-ask the scope later, when a different storage may be current + $conditionOriginalTypes = []; foreach ($conditionSpecifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; @@ -160,6 +164,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol $expr, $conditionType, ); + $conditionOriginalTypes[$exprString] = $scopeType; } foreach ($conditionSpecifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { @@ -177,6 +182,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol $expr, $conditionType, ); + $conditionOriginalTypes[$exprString] = $scopeType; } if (count($conditionExpressionTypes) > 0) { @@ -230,9 +236,10 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol // The dropped self-condition narrowed the target; without it the // holder must allow the values it excluded, or it over-narrows when - // only the remaining conditions hold. So union back the complement. + // only the remaining conditions hold. So union back the complement, + // computed from the type pinned when the condition set was built. if ($droppedSelfCondition !== null) { - $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); + $complement = TypeCombinator::remove($conditionOriginalTypes[$exprString], $droppedSelfCondition->getType()); if (!$complement instanceof NeverType) { $holderType = TypeCombinator::union($holderType, $complement); } From 3e923568f0b68058f3071023d86cbcb6c217938c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 3 Jul 2026 13:03:35 +0200 Subject: [PATCH 240/378] Compose createSubjectTypes entries from the result's own facts, never TypeSpecifier::create specifyTypesCallback/createTypesCallback composition no longer reaches TypeSpecifier::create()/createForExpr() - their old-world machinery re-derives from the scope what the subject's ExpressionResult already carries: - a call whose own execution is (possibly) impure gets no remembered type, gated by the result's own ImpurePoint instead of re-asking reflection, - a chain containing a nullsafe narrows its short-circuited plain twin, decided by the containsNullsafe flag and the memoized result type instead of the scope-type probe, - instanceof/list subjects yield nothing, everything else the plain sure/sureNot entry. AssignOpHandler wires the createTypesCallback for ??= (a constraint on $x ??= y constrains the assigned variable), which create()'s AssignOp\Coalesce arm used to recover by unwrapping. create()/createForExpr() stay for the @api extension path only. --- src/Analyser/ExprHandler/AssignOpHandler.php | 8 ++ .../Helper/DefaultNarrowingHelper.php | 101 +++++++++++++----- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index ae8156f9f5f..5083c3ddf4d 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -153,6 +153,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }; $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + $createTypesCallback = null; + if ($expr instanceof Expr\AssignOp\Coalesce) { + // a type constraint on `$x ??= y` constrains the assigned variable - + // what TypeSpecifier::create() recovered by its AssignOp\Coalesce arm + $createTypesCallback = fn (MutatingScope $cs, Type $constraintType, TypeSpecifierContext $cctx): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $nodeScopeResolver->findStoredResult($expr->var, $cs), $constraintType, $cctx); + } // processAssignVar asks getType($expr) for the value to assign; store this // result first so it resolves from the typeCallback above rather than @@ -167,6 +173,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, )); $assignResult = $this->assignHandler->processAssignVar( @@ -244,6 +251,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto impurePoints: $impurePoints, typeCallback: $typeCallback, specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 0b8ccdaa241..89a299fe788 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -12,11 +12,12 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; @@ -30,6 +31,7 @@ use PHPStan\Type\NullType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticTypeFactory; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\Type; use function array_reverse; use function is_string; @@ -50,7 +52,8 @@ final class DefaultNarrowingHelper public function __construct( private ExprPrinter $exprPrinter, - private TypeSpecifier $typeSpecifier, + #[AutowiredParameter] + private bool $rememberPossiblyImpureFunctionValues, ) { } @@ -91,24 +94,16 @@ public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): } /** - * A greatly simplified TypeSpecifier::create() for a subject the calling - * handler has already processed: one sure (truthy) or sureNot (falsey) - * entry for the subject node. A coalesce subject narrows its left side - * when the narrowed type rules the right side in or out. No purity gates, - * no nullsafe chain-walking, no assignment fan-out - an entry about an - * assignment narrows the assigned variables in the appliers, and the - * subject's own narrowing composes in through - * ExpressionResult::getSpecifiedTypesForScope() at the call site. - */ - /** - * A greatly simplified TypeSpecifier::create() for a subject the calling - * handler has already processed: the subject's own result says how a type - * constraint on it translates into entries (an assignment fans out to the - * assigned variable, a coalesce delegates to its left side); without a - * createTypesCallback a single sure (truthy) or sureNot (falsey) entry - * for the subject node is emitted. No purity gates, no nullsafe - * chain-walking, no structural unwrapping - the handlers that own those - * nodes compose their children's results inside-out. + * The new-world counterpart of TypeSpecifier::create() for a subject the + * calling handler has already processed. The subject's own result says how + * a type constraint on it translates into entries (an assignment fans out + * to the assigned variable, a coalesce delegates to its left side); without + * a createTypesCallback the entries are composed here from the result's own + * facts: a call whose execution is (possibly) impure gets none, a chain + * containing a nullsafe additionally narrows its short-circuited plain twin. + * TypeSpecifier::create()/createForExpr() are never reached - their + * old-world machinery re-derives from the scope what the result already + * carries. */ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes { @@ -119,11 +114,67 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR } } - // No composable result (a synthetic node, or a subject whose handler wired - // no createTypesCallback): fall back to the raw-Expr create(), which does the - // structural fan-out (assignment / remembered wrapper) and createForExpr. For - // a plain subject this equals the single sure/sureNot entry it used to emit. - return $this->typeSpecifier->create($subject, $type, $context, $s); + if ($subject instanceof Expr\Instanceof_ || $subject instanceof Expr\List_) { + return new SpecifiedTypes([], []); + } + + $exprToSpecify = $subject; + if ($subjectResult !== null) { + // a call whose own execution is (possibly) impure must not get a + // remembered type - the gate reads the result's own impure point + // instead of re-asking reflection like the old create() did + if ( + $subject instanceof Expr\FuncCall + || $subject instanceof Expr\MethodCall + || $subject instanceof Expr\StaticCall + || $subject instanceof Expr\NullsafeMethodCall + ) { + foreach ($subjectResult->getImpurePoints() as $impurePoint) { + if ($impurePoint->getNode() !== $subject) { + continue; + } + if ($impurePoint->isCertain() || !$this->rememberPossiblyImpureFunctionValues) { + return new SpecifiedTypes([], []); + } + + break; + } + } + + // a chain containing a nullsafe narrows its short-circuited plain + // twin too, when the constraint (or the subject's own type) rules + // the short-circuit null out - the containsNullsafe flag and the + // memoized result type replace the old scope-type probe + if ($subjectResult->containsNullsafe()) { + if ($context->true()) { + $nullRuledOut = $type->isNull()->no() || $subjectResult->getTypeOnScope($s, $s->nativeTypesPromoted)->isNull()->no(); + } elseif ($context->false()) { + $nullRuledOut = TypeCombinator::containsNull($type) || $subjectResult->getTypeOnScope($s, $s->nativeTypesPromoted)->isNull()->no(); + } else { + $nullRuledOut = false; + } + + if ($nullRuledOut) { + $exprToSpecify = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($subject); + } + } + } + + $sureTypes = []; + $sureNotTypes = []; + if ($context->false()) { + $sureNotTypes[$this->exprPrinter->printExpr($exprToSpecify)] = [$exprToSpecify, $type]; + if ($exprToSpecify !== $subject) { + $sureNotTypes[$this->exprPrinter->printExpr($subject)] = [$subject, $type]; + } + } elseif ($context->true()) { + $sureTypes[$this->exprPrinter->printExpr($exprToSpecify)] = [$exprToSpecify, $type]; + if ($exprToSpecify !== $subject) { + $sureTypes[$this->exprPrinter->printExpr($subject)] = [$subject, $type]; + } + } + + return new SpecifiedTypes($sureTypes, $sureNotTypes); } /** From 56e1845dd676a127a005265a1e598af15ed8092a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 3 Jul 2026 14:05:01 +0200 Subject: [PATCH 241/378] Collapse filterByTruthyValue/filterByFalseyValue onto applySpecifiedTypes The two @api filter methods keep their names, caches and the specifyTypesInCondition entry point, but apply the computed narrowing through applySpecifiedTypes() - the same applier the handlers' specifyTypesCallback path uses - instead of the old-world filterBySpecifiedTypes(), which is deleted together with its addTypeToExpression/removeTypeFromExpression state readers' certainty sibling setExpressionCertainty(). One applier, one semantics: the Yes-certainty holder discipline, the result-based current-type reads and the isset certainty-keeping now hold on every narrowing path. Fixes four previously known-red tests: scope-in-enum-match-arm-body, nsrt/bug-10055, CallMethodsRuleTest::testBug12422 and DefinedVariableRuleTest::testDynamicAccess. --- phpstan-baseline.neon | 2 +- src/Analyser/MutatingScope.php | 102 ++------------------------------- 2 files changed, 7 insertions(+), 97 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34955d177cc..01737d71a1d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 5 + count: 3 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 27feda8afa5..6b978ded236 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3315,25 +3315,8 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } - private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): self - { - if ($this->hasExpressionType($expr)->no()) { - throw new ShouldNotHappenException(); - } - - $originalExprType = $this->getType($expr); - $nativeType = $this->getNativeType($expr); - - return $this->specifyExpressionType( - $expr, - $originalExprType, - $nativeType, - $certainty, - ); - } - /** - * Certainty change for applySpecifiedTypes(): unlike setExpressionCertainty(), + * Certainty change for applySpecifiedTypes(): * it keeps the type already held for the expression instead of re-reading it * via getType(). getType() only reports the type of Yes-certainty holders, so * for a maybe-defined variable it broadens to the original type - which would @@ -3443,7 +3426,7 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $scope = $this->applySpecifiedTypes($specifiedTypes); $this->truthyScopes[$exprString] = $scope; return $scope; @@ -3460,94 +3443,21 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $scope = $this->applySpecifiedTypes($specifiedTypes); $this->falseyScopes[$exprString] = $scope; return $scope; } /** - * @return static - */ - public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self - { - $typeSpecifications = ScopeOps::buildTypeSpecifications($specifiedTypes->getSureTypes(), $specifiedTypes->getSureNotTypes()); - - $scope = $this; - $specifiedExpressions = []; - foreach ($typeSpecifications as $typeSpecification) { - $expr = $typeSpecification['expr']; - $type = $typeSpecification['type']; - - if ($expr instanceof IssetExpr) { - $issetExpr = $expr; - $expr = $issetExpr->getExpr(); - - if ($typeSpecification['sure']) { - $scope = $scope->setExpressionCertainty( - $expr, - TrinaryLogic::createMaybe(), - ); - } else { - $scope = $scope->unsetExpression($expr); - } - - continue; - } - - if ( - !$typeSpecification['sure'] - && $expr instanceof Variable && is_string($expr->name) - && $scope->hasVariableType($expr->name)->no() - ) { - // removing type from a certainly-undefined variable cannot make - // it defined; a sure specification (e.g. is_string($a)) still can - - // the condition can only hold for a defined variable - continue; - } - - if ($typeSpecification['sure']) { - if ($specifiedTypes->shouldOverwrite()) { - $scope = $scope->assignExpression($expr, $type, $type); - } else { - $scope = $scope->addTypeToExpression($expr, $type); - } - } else { - $scope = $scope->removeTypeFromExpression($expr, $type); - } - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); - } - - $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); - - /** @var static */ - return $scope->scopeFactory->create( - $scope->context, - $scope->isDeclareStrictTypes(), - $scope->getFunction(), - $scope->getNamespace(), - $scope->expressionTypes, - $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), - $scope->inClosureBindScopeClasses, - $scope->anonymousFunctionReflection, - $scope->inFirstLevelStatement, - $scope->currentlyAssignedExpressions, - $scope->currentlyAllowedUndefinedExpressions, - $scope->inFunctionCallsStack, - $scope->afterExtractCall, - $scope->parentScope, - $scope->nativeTypesPromoted, - ); - } - - /** - * New-world counterpart of filterBySpecifiedTypes. + * Applies computed narrowing to this scope. * * The types inside SpecifiedTypes were already computed from ExpressionResults * by the specifyTypesCallback of an ExprHandler. This method must never call * Scope::getType() - it only combines the given types with already-tracked * expression type holders. + * + * @return static */ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { From 4d665e0b577084515b91ead30d24f4979466be7c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 3 Jul 2026 20:41:38 +0200 Subject: [PATCH 242/378] Narrow match scopes via applySpecifiedTypes instead of filterBy* MatchHandler was the last ExprHandler calling filterByTruthyValue/ filterByFalseyValue. Its synthetic subject-comparison and filtering expressions are now processed on demand once per (expression, scope) and consumed for the type and both narrowing contexts, applied through applySpecifiedTypes - the arm-cond synthetic serves its truthy and falsey scopes from a single processing where filterBy* re-entered the TypeSpecifier dispatcher per context. The bool-variable contradiction probe composes the same way. The @api filterBy* methods now have no engine-side callers left - only rules and type extensions. --- src/Analyser/ExprHandler/MatchHandler.php | 39 ++++++++++++++--------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index ef0118f5896..5922a9f7cee 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -248,10 +248,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $filteringExpr = $this->getFilteringExprForMatchArm($expr, $conditionExprs); - $matchArmBodyScope = $matchScope->addTypeToExpression( + $condNarrowedScope = $matchScope->addTypeToExpression( $expr->cond, $conditionCaseType, - )->filterByTruthyValue($filteringExpr); + ); + $matchArmBodyScope = $condNarrowedScope->applySpecifiedTypes( + $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $condNarrowedScope)->getSpecifiedTypesForScope($condNarrowedScope, TypeSpecifierContext::createTruthy()), + ); $matchArmBody = new MatchExpressionArmBody($matchArmBodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -332,25 +335,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armCondResult->getImpurePoints()); $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); $armCondResultScope = $armCondResult->getScope(); - // the `subject === cond` comparison is synthetic - price it on demand - // against the current storage instead of re-walking via Scope::getType(). + // the `subject === cond` comparison is synthetic - process it on + // demand once and consume its type and both narrowing contexts. + $armCondSyntheticResult = $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope); $armCondType = $this->treatPhpDocTypesAsCertain - ? $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope)->getTypeOnScope($armCondResultScope, false) + ? $armCondSyntheticResult->getTypeOnScope($armCondResultScope, false) : $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($armCondResultScope, true); if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } - $armCondScope = $armCondResultScope->filterByFalseyValue($armCondExpr); + $armCondScope = $armCondResultScope->applySpecifiedTypes($armCondSyntheticResult->getSpecifiedTypesForScope($armCondResultScope, TypeSpecifierContext::createFalsey())); + $armCondTruthyScope = $armCondResultScope->applySpecifiedTypes($armCondSyntheticResult->getSpecifiedTypesForScope($armCondResultScope, TypeSpecifierContext::createTruthy())); if ($bodyScope === null) { - $bodyScope = $armCondResultScope->filterByTruthyValue($armCondExpr); + $bodyScope = $armCondTruthyScope; } else { - $bodyScope = $bodyScope->mergeWith($armCondResultScope->filterByTruthyValue($armCondExpr)); + $bodyScope = $bodyScope->mergeWith($armCondTruthyScope); } $filteringExprs[] = $armCond; } $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); - $bodyScope ??= $matchScope->filterByTruthyValue($filteringExpr); + $filteringExprResult = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope); + $bodyScope ??= $matchScope->applySpecifiedTypes($filteringExprResult->getSpecifiedTypesForScope($matchScope, TypeSpecifierContext::createTruthy())); $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -373,15 +379,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // always false is unreachable and does not contribute to the result // type. The filtering expression is synthetic - price it on demand // against the current storage instead of re-walking via Scope::getType(). - $filteringExprType = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope)->getTypeOnScope($matchScope, false); + $filteringExprType = $filteringExprResult->getTypeOnScope($matchScope, false); if (!$filteringExprType->isFalse()->yes()) { $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; } - $matchScope = $armCondScope->filterByFalseyValue($filteringExpr); + $matchScope = $armCondScope->applySpecifiedTypes( + $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()), + ); } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { - if ($this->isScopeConditionallyImpossible($matchScope)) { + if ($this->isScopeConditionallyImpossible($nodeScopeResolver, $matchScope)) { $hasAlwaysTrueCond = true; $matchScope = $matchScope->addTypeToExpression($expr->cond, new NeverType()); } @@ -485,7 +493,7 @@ private function getFilteringExprForMatchArm(Match_ $expr, array $conditions): B ); } - private function isScopeConditionallyImpossible(MutatingScope $scope): bool + private function isScopeConditionallyImpossible(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope): bool { $boolVars = []; foreach ($scope->getDefinedVariables() as $varName) { @@ -504,14 +512,15 @@ private function isScopeConditionallyImpossible(MutatingScope $scope): bool // Check if any boolean variable's both truth values lead to contradictions foreach ($boolVars as $varName) { $varExpr = new Variable($varName); + $varExprResult = $nodeScopeResolver->processSyntheticOnDemand($varExpr, $scope); - $truthyScope = $scope->filterByTruthyValue($varExpr); + $truthyScope = $scope->applySpecifiedTypes($varExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); $truthyContradiction = $this->scopeHasNeverVariable($truthyScope, $boolVars); if (!$truthyContradiction) { continue; } - $falseyScope = $scope->filterByFalseyValue($varExpr); + $falseyScope = $scope->applySpecifiedTypes($varExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); $falseyContradiction = $this->scopeHasNeverVariable($falseyScope, $boolVars); if ($falseyContradiction) { return true; From 7349060ceffcacfd339048d7141184ce88e766ee Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 20:23:04 +0200 Subject: [PATCH 243/378] Compute call narrowing on the evaluation scope, flavoured by the asking scope The specify callbacks of the four call handlers pass the captured beforeScope into their narrowing methods - the type-specifying extensions, asserts and conditional return types now run on the evaluation scope, exactly like the dynamic return type extensions already do on the type side. The asking scope contributes only the native-vs-phpdoc flavour (promoting beforeScope for the native re-check that produces the treatPhpDocTypesAsCertain tip). These callbacks' scope dependency is thereby reduced to the flavour flag. --- src/Analyser/ExprHandler/FuncCallHandler.php | 4 +++- src/Analyser/ExprHandler/MethodCallHandler.php | 4 +++- src/Analyser/ExprHandler/NewHandler.php | 4 +++- src/Analyser/ExprHandler/StaticCallHandler.php | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 5835ccde822..c9bbf849278 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -342,7 +342,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - $s, + // the narrowing computes on the evaluation scope; only the asked + // flavour comes from the asking scope + $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $nameResult, diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 4410f7fd4a3..13f8c539a99 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -193,7 +193,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - $s, + // the narrowing computes on the evaluation scope; only the asked + // flavour comes from the asking scope + $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $varResult, diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index e5e7b611b54..f31670254bf 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -238,7 +238,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nativeTypesPromoted ? null : $resolvedParametersAcceptor, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( - $s, + // the narrowing computes on the evaluation scope; only the asked + // flavour comes from the asking scope + $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $resolvedParametersAcceptor, $specifyContext, diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 2fa84572129..d4d858f3253 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -270,7 +270,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - $s, + // the narrowing computes on the evaluation scope; only the asked + // flavour comes from the asking scope + $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $classResult, From 8ca03f144165bab047e57075865771ad573a27a7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 21:24:11 +0200 Subject: [PATCH 244/378] Compose null-identity narrowing from operand results in IdenticalNarrowingHelper First slice of the clean-room equality rewrite: comparisons against null narrow the subject through DefaultNarrowingHelper::createSubjectTypes with the operands' captured ExpressionResults - no Scope asks, no synthetic Identical node, TypeSpecifier::create() never reached. NotIdentical is the same narrowing in the negated context. Uncovered shapes return null and fall back to EqualityTypeSpecifyingHelper; FuncCall subjects stay there because comparing them against null also narrows their arguments (array_key_first($a) !== null makes $a non-empty), which the new helper does not compose yet. The composed path fixes the nullsafe-assignment narrowing the old helper lost (nsrt/bug-10482.php, nsrt/nullsafe.php). --- src/Analyser/ExprHandler/BinaryOpHandler.php | 28 +- .../Helper/IdenticalNarrowingHelper.php | 75 ++++++ .../nsrt/equality-narrowing-new-world.php | 247 ++++++++++++++++++ 3 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 08adfc32688..1d935ae5205 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -20,6 +20,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -68,6 +69,7 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, @@ -250,17 +252,33 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { $resultFor = static fn (Expr $e): ?ExpressionResult => $e === $expr->left ? $leftResult : ($e === $expr->right ? $rightResult : null); + if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { + // `!==` narrowing is the `===` narrowing in the negated context - + // no synthetic Identical node. A null context never negates. + if (!($context->null() && $expr instanceof BinaryOp\NotIdentical)) { + $newWorldTypes = $this->identicalNarrowingHelper->specifyIdentical( + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotIdentical ? $context->negate() : $context, + // the narrowing composes on the evaluation scope; only the + // asked flavour comes from the asking scope + $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + ); + if ($newWorldTypes !== null) { + return $newWorldTypes->setRootExpr($expr); + } + } + } + if ($expr instanceof BinaryOp\Identical) { return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context, $resultFor); } if ($expr instanceof BinaryOp\NotIdentical) { - // negating the context is exactly what a BooleanNot around the - // Identical would do - direct computation avoids synthesizing a - // BooleanNot node (on-demand re-processing once it is migrated). - // A null context never negates (BooleanNot defaults on it too). if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php new file mode 100644 index 00000000000..350b2d53df2 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -0,0 +1,75 @@ +null()) { + return null; + } + + // slice 1 covers comparisons against null; everything else falls back + if ($left instanceof Expr\ConstFetch && $left->name->toLowerString() === 'null') { + $subject = $right; + $subjectResult = $rightResult; + } elseif ($right instanceof Expr\ConstFetch && $right->name->toLowerString() === 'null') { + $subject = $left; + $subjectResult = $leftResult; + } else { + return null; + } + + // function calls against null narrow their arguments too + // (array_key_first($a) !== null makes $a non-empty) - not ported yet + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if ($unwrappedSubject instanceof Expr\FuncCall) { + return null; + } + + return $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + new NullType(), + $context, + ); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php new file mode 100644 index 00000000000..a54d93a73ac --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -0,0 +1,247 @@ += 8.1 + +declare(strict_types = 1); + +namespace EqualityNarrowingNewWorld; + +use function PHPStan\Testing\assertType; + +class Foo +{ + + public const BAR = 'bar'; + +} + +enum Suit: string +{ + + case Hearts = 'H'; + case Spades = 'S'; + +} + +class Basics +{ + + public function identicalNull(?int $a): void + { + if ($a === null) { + assertType('null', $a); + } else { + assertType('int', $a); + } + if ($a !== null) { + assertType('int', $a); + } else { + assertType('null', $a); + } + if (null === $a) { + assertType('null', $a); + } else { + assertType('int', $a); + } + } + + public function identicalLiteral(int $a, string $s): void + { + if ($a === 5) { + assertType('5', $a); + } else { + assertType('int|int<6, max>', $a); + } + if (5 === $a) { + assertType('5', $a); + } + if ($s === 'foo') { + assertType("'foo'", $s); + } else { + assertType("string", $s); + } + } + + public function identicalBool(bool $b, int $i): void + { + if ($b === true) { + assertType('true', $b); + } else { + assertType('false', $b); + } + if ($b !== false) { + assertType('true', $b); + } + if (($i > 3) === true) { + assertType('int<4, max>', $i); + } + if (($i > 3) === false) { + assertType('int', $i); + } + } + + /** + * @param 'a'|'b'|'c' $abc + * @param int|string $is + */ + public function unionMembers(string $abc, $is): void + { + if ($abc === 'b') { + assertType("'b'", $abc); + } else { + assertType("'a'|'c'", $abc); + } + if ($is === 'x') { + assertType("'x'", $is); + } else { + assertType('int|string', $is); + } + } + + /** + * @param int|null $a + * @param string|null $b + */ + public function bothSidesSpecifiable($a, $b): void + { + if ($a === $b) { + assertType('null', $a); + assertType('null', $b); + } + if ($a !== $b) { + // nothing certain about either side + assertType('int|null', $a); + assertType('string|null', $b); + } + } + + public function enumCases(Suit $suit): void + { + if ($suit === Suit::Hearts) { + assertType('EqualityNarrowingNewWorld\Suit::Hearts', $suit); + } else { + assertType('EqualityNarrowingNewWorld\Suit::Spades', $suit); + } + if ($suit !== Suit::Spades) { + assertType('EqualityNarrowingNewWorld\Suit::Hearts', $suit); + } + } + + public function classConstant(string $s): void + { + if ($s === Foo::BAR) { + assertType("'bar'", $s); + } + } + + /** @param array $arr */ + public function countNarrowing(array $arr): void + { + if (count($arr) === 0) { + assertType('array{}', $arr); + } else { + assertType('non-empty-array', $arr); + } + if (count($arr) === 2) { + assertType('non-empty-array', $arr); + } + if (count($arr) !== 0) { + assertType('non-empty-array', $arr); + } + } + + public function strlenNarrowing(string $s): void + { + if (strlen($s) === 0) { + assertType("''", $s); + } else { + assertType('non-empty-string', $s); + } + if (strlen($s) !== 0) { + assertType('non-empty-string', $s); + } + } + + public function getClassNarrowing(object $o): void + { + if (get_class($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + } + + /** @param mixed $m */ + public function looseEquality($m, ?string $s): void + { + if ($m == null) { + assertType("0|0.0|''|array{}|false|null", $m); + } else { + assertType("mixed~(0|0.0|''|array{}|false|null)", $m); + } + if ($s == false) { + assertType("''|'0'|null", $s); + } else { + assertType('non-falsy-string', $s); + } + if ($s != null) { + assertType('non-empty-string', $s); + } else { + assertType("''|null", $s); + } + } + + /** + * @param int|string $a + */ + public function narrowAgainstExpression($a, int $b): void + { + if ($a === $b) { + assertType('int', $a); + } else { + assertType('int|string', $a); + } + } + + public function nestedInBoolean(?int $a, ?string $b): void + { + if ($a !== null && $b !== null) { + assertType('int', $a); + assertType('string', $b); + } + if ($a === null || $b === null) { + assertType('int|null', $a); + } else { + assertType('int', $a); + assertType('string', $b); + } + } + + /** @var self|null */ + private $selfOrNull; + + public function propertyChain(): void + { + if ($this->selfOrNull !== null) { + assertType('EqualityNarrowingNewWorld\Basics', $this->selfOrNull); + } + } + + public function assignInCondition(?int $a): void + { + if (($b = $a) !== null) { + assertType('int', $b); + assertType('int', $a); + } + } + + /** @param list $list */ + public function funcCallAgainstNull(array $list): void + { + if (array_key_first($list) !== null) { + assertType('non-empty-list', $list); + } + if (($key = array_key_first($list)) !== null) { + assertType('int<0, max>', $key); + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } + } + +} From 95ca533f0a07774777afbef1304bab4852fa03b5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 21:35:41 +0200 Subject: [PATCH 245/378] Narrow bool-literal identity comparisons in IdenticalNarrowingHelper Second slice of the clean-room equality rewrite: comparisons against a true/false literal pin the constant through the subject's entries and run the subject's own narrowing in the matching bool context - identity, not truthiness. A nullsafe chain skips the delegation outside the true context because it may have short-circuited instead of producing the constant. Narrowing by an AlwaysRememberedExpr now delegates to the inner expression's result (keeping the wrapper's own default entry) - what TypeSpecifier::specifyTypesInCondition() did by unwrapping the node and specifying both keys. Without it, delegated bool contexts died at the wrapper's default truthy/falsey entry (match arms compare the remembered condition: bug-9694). --- .../Helper/IdenticalNarrowingHelper.php | 47 ++++++++++++++++--- .../Virtual/AlwaysRememberedExprHandler.php | 7 ++- .../nsrt/equality-narrowing-new-world.php | 33 +++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 350b2d53df2..2b02943c155 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -9,7 +9,9 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NullType; +use function in_array; /** * New-world narrowing for `===` (and, via a negated context, `!==`): composed @@ -45,31 +47,62 @@ public function specifyIdentical( return null; } - // slice 1 covers comparisons against null; everything else falls back - if ($left instanceof Expr\ConstFetch && $left->name->toLowerString() === 'null') { + // slices 1+2 cover comparisons against a null/true/false literal; + // everything else falls back + if ($left instanceof Expr\ConstFetch && in_array($left->name->toLowerString(), ['null', 'true', 'false'], true)) { + $constantName = $left->name->toLowerString(); $subject = $right; $subjectResult = $rightResult; - } elseif ($right instanceof Expr\ConstFetch && $right->name->toLowerString() === 'null') { + } elseif ($right instanceof Expr\ConstFetch && in_array($right->name->toLowerString(), ['null', 'true', 'false'], true)) { + $constantName = $right->name->toLowerString(); $subject = $left; $subjectResult = $leftResult; } else { return null; } - // function calls against null narrow their arguments too - // (array_key_first($a) !== null makes $a non-empty) - not ported yet + // function calls against a constant narrow their arguments too + // (array_key_first($a) !== null makes $a non-empty, array_search(...) + // !== false narrows the haystack) - not ported yet $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if ($unwrappedSubject instanceof Expr\FuncCall) { return null; } - return $this->defaultNarrowingHelper->createSubjectTypes( + if ($constantName === 'null') { + return $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + new NullType(), + $context, + ); + } + + // a bool literal pins the constant through the entries and runs the + // subject's own narrowing in the matching bool context - identity, + // not truthiness: `=== false` is the false context, not falsey + $types = $this->defaultNarrowingHelper->createSubjectTypes( $evaluationScope, $subject, $subjectResult, - new NullType(), + new ConstantBooleanType($constantName === 'true'), $context, ); + + // a nullsafe chain that did not produce the constant may have + // short-circuited instead - its own narrowing only holds when the + // comparison succeeded + if (!$context->true() && ($unwrappedSubject instanceof Expr\NullsafeMethodCall || $unwrappedSubject instanceof Expr\NullsafePropertyFetch)) { + return $types; + } + + $boolContext = $constantName === 'true' ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + + return $types->unionWith($subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? $boolContext : $boolContext->negate(), + )); } } diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 4241ff8a3d8..8954e456941 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -61,7 +61,12 @@ public function processExpr( throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + // Narrowing by the remembered wrapper is narrowing by the inner + // expression (TypeSpecifier unwrapped it and specified both keys); + // the wrapper node itself keeps the default truthy/falsey entry. + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)->unionWith( + $innerResult->getSpecifiedTypesForScope($s, $context), + ), // A type constraint on the remembered wrapper constrains both the wrapper // node (under its __phpstanRemembered(...) key) and the inner expression - // what TypeSpecifier::create() recovered by fanning the AlwaysRememberedExpr diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index a54d93a73ac..1f83d2b8d55 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -231,6 +231,39 @@ public function assignInCondition(?int $a): void } } + public function flag(): bool + { + return true; + } + + /** @param array|false $arrOrFalse */ + public function boolConstAgainstExpressions(?self $s, $arrOrFalse, ?bool $nb): void + { + if ($s?->flag() === false) { + assertType('EqualityNarrowingNewWorld\Basics', $s); + } else { + assertType('EqualityNarrowingNewWorld\Basics|null', $s); + } + if ($s?->flag() === true) { + assertType('EqualityNarrowingNewWorld\Basics', $s); + } + if ($arrOrFalse !== false) { + assertType('array', $arrOrFalse); + } else { + assertType('false', $arrOrFalse); + } + if ($nb === true) { + assertType('true', $nb); + } else { + assertType('false|null', $nb); + } + if ($nb !== false) { + assertType('true|null', $nb); + } else { + assertType('false', $nb); + } + } + /** @param list $list */ public function funcCallAgainstNull(array $list): void { From 59b981fd69eb7873867772b2277e0cbbe637b825 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 21:58:05 +0200 Subject: [PATCH 246/378] Narrow scalar-literal identity comparisons in IdenticalNarrowingHelper Third slice of the clean-room equality rewrite: comparisons against a scalar literal or class constant pin the single-valued side onto the other operand, the composed form of the finite-types narrowing at the tail of the old identical path. A single-valued subject pins back onto the literal side too. Function-call subjects and $a::class stay on the old-world path - their comparisons narrow more than the subject itself (count/strlen/get_class arguments, the class of $a::class). A statically decided comparison tells the false context nothing: the branch is dead on the certain flavour, and subtracting the constant would leak into the wider native flavour (mixed~'ab' cost the treatPhpDocTypesAsCertain tips their reason to exist). The NeverType entries mirror the old tail's no-op. The null slice is deliberately exempt: its subtraction entry must keep firing assign-time conditional holders even when the subject's type already excludes null - the old path's blanket guard there is what kept bug-10482 red. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 14 ++ .../Helper/IdenticalNarrowingHelper.php | 162 +++++++++++++++++- 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 1d935ae5205..d848b53cf31 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -267,6 +267,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the narrowing composes on the evaluation scope; only the // asked flavour comes from the asking scope $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + // the comparison's own verdict, in Identical semantics + static function () use ($nodeScopeResolver, $expr, $scope): Type { + $ownType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + if ($expr instanceof BinaryOp\NotIdentical) { + if ($ownType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($ownType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + } + + return $ownType; + }, ); if ($newWorldTypes !== null) { return $newWorldTypes->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 2b02943c155..53a48eb2dde 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -3,6 +3,8 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use PhpParser\Node\Expr; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\SpecifiedTypes; @@ -10,7 +12,10 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; +use function count; use function in_array; /** @@ -34,6 +39,10 @@ public function __construct( { } + /** + * @param callable(): Type $identicalTypeCallback the comparison's own type + * in Identical semantics (the caller flips a NotIdentical verdict) + */ public function specifyIdentical( Expr $left, Expr $right, @@ -41,6 +50,7 @@ public function specifyIdentical( ExpressionResult $rightResult, TypeSpecifierContext $context, MutatingScope $evaluationScope, + callable $identicalTypeCallback, ): ?SpecifiedTypes { if ($context->null()) { @@ -48,7 +58,7 @@ public function specifyIdentical( } // slices 1+2 cover comparisons against a null/true/false literal; - // everything else falls back + // everything else falls through to the scalar-literal slice below if ($left instanceof Expr\ConstFetch && in_array($left->name->toLowerString(), ['null', 'true', 'false'], true)) { $constantName = $left->name->toLowerString(); $subject = $right; @@ -58,18 +68,19 @@ public function specifyIdentical( $subject = $left; $subjectResult = $leftResult; } else { - return null; + return $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); } - // function calls against a constant narrow their arguments too - // (array_key_first($a) !== null makes $a non-empty, array_search(...) - // !== false narrows the haystack) - not ported yet - $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; - if ($unwrappedSubject instanceof Expr\FuncCall) { + if (!$this->isSubjectCoveredAgainstConstant($subject)) { return null; } if ($constantName === 'null') { + // deliberately NOT guarded by specifyDecidedComparison(): a decided + // null comparison still emits its subtraction entry so assign-time + // conditional holders fire ($id = $x?->prop; if ($id !== null) makes + // $x non-null even when $id's own type already excludes null) - the + // old path's blanket guard here is what kept bug-10482 red return $this->defaultNarrowingHelper->createSubjectTypes( $evaluationScope, $subject, @@ -93,6 +104,7 @@ public function specifyIdentical( // a nullsafe chain that did not produce the constant may have // short-circuited instead - its own narrowing only holds when the // comparison succeeded + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if (!$context->true() && ($unwrappedSubject instanceof Expr\NullsafeMethodCall || $unwrappedSubject instanceof Expr\NullsafePropertyFetch)) { return $types; } @@ -105,4 +117,140 @@ public function specifyIdentical( )); } + /** + * Slice 3: comparisons against a scalar literal or a class constant + * (`$a === 5`, `$s === Foo::BAR`, `$suit === Suit::Hearts`) pin the + * single-valued side onto the other operand - the composed form of the + * finite-types narrowing at the tail of the old identical path. + */ + /** + * @param callable(): Type $identicalTypeCallback + */ + private function specifyAgainstScalarLiteral( + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if ($this->isScalarLiteral($left)) { + $constantExpr = $left; + $constantResult = $leftResult; + $subject = $right; + $subjectResult = $rightResult; + } elseif ($this->isScalarLiteral($right)) { + $constantExpr = $right; + $constantResult = $rightResult; + $subject = $left; + $subjectResult = $leftResult; + } else { + return null; + } + + if (!$this->isSubjectCoveredAgainstConstant($subject)) { + return null; + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $constantType = $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($constantType->getFiniteTypes()) !== 1) { + // a class constant does not have to be single-valued + return null; + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + $constantType, + $context, + ); + + // a single-valued subject pins its value onto the literal side too + $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($subjectType->getFiniteTypes()) === 1) { + $types = $types->unionWith($this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $constantExpr, + $constantResult, + $subjectType, + $context, + )); + } + + return $types; + } + + /** + * A statically decided comparison tells the false context nothing: the + * branch is dead on the certain flavour, and subtracting the constant + * would wrongly leak into the wider native flavour (mixed~'ab'). The + * NeverType entries mirror the old identical tail's no-op. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyDecidedComparison( + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if (!$context->false()) { + return null; + } + + $identicalType = $identicalTypeCallback(); + $isTrue = $identicalType->isTrue()->yes(); + if (!$isTrue && !$identicalType->isFalse()->yes()) { + return null; + } + + $never = new NeverType(); + $contextForTypes = $isTrue ? $context->negate() : $context; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $never, $contextForTypes)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $never, $contextForTypes), + ); + } + + private function isScalarLiteral(Expr $expr): bool + { + if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\String_ || $expr instanceof Scalar\Float_) { + return true; + } + + // Foo::BAR, Suit::Hearts, Foo::class - but not $a::class, whose + // narrowing works on $a (an old-world block, not ported yet) + return $expr instanceof Expr\ClassConstFetch + && $expr->class instanceof Name + && !$expr->name instanceof Expr; + } + + /** + * Subjects whose comparison against a constant narrows more than the + * subject expression itself stay on the old-world path for now: function + * calls narrow their arguments (array_key_first($a) !== null makes $a + * non-empty, count($a) === 0 empties $a), `$a::class` narrows $a. + */ + private function isSubjectCoveredAgainstConstant(Expr $subject): bool + { + $unwrapped = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if ($unwrapped instanceof Expr\FuncCall) { + return false; + } + + return !($unwrapped instanceof Expr\ClassConstFetch && $unwrapped->class instanceof Expr); + } + } From fcd671cc2f70d09d162a137e55f005b66d3467d9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 22:04:46 +0200 Subject: [PATCH 247/378] Compose array_key_first-family null narrowing through the FuncCall constraint fan A null constraint on array_key_first/array_key_last/array_find_key narrows the array argument (both directions for first/last, non-empty only for find_key). The narrowing moves from EqualityTypeSpecifyingHelper's identical block into FuncCallHandler's createTypesCallback, where every constraint route composes it for free - a comparison against the raw call, the assign fan of $key = array_key_first($a) inside a condition, and the assign-time synthesized comparison that builds conditional holders all reach the same entries. With the argument narrowing composed, null-literal comparisons no longer guard function-call subjects in IdenticalNarrowingHelper - the null slice is fully off the old-world path. --- src/Analyser/ExprHandler/FuncCallHandler.php | 35 +++++++++++++++++-- .../Helper/IdenticalNarrowingHelper.php | 8 +++-- .../nsrt/equality-narrowing-new-world.php | 14 ++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index c9bbf849278..073fe543dcf 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -356,9 +356,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // function call narrows the call itself - the inside-out equivalent of // createForExpr's FuncCall purity gate + tail entry. An impure call narrows to // nothing. - $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult) - ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) - : new SpecifiedTypes([], []); + $createTypesCallback = function (MutatingScope $s, Type $type, TypeSpecifierContext $createContext) use ($nodeScopeResolver, $expr, $nameResult): SpecifiedTypes { + if (!$this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult)) { + return new SpecifiedTypes([], []); + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext); + + // array_key_first/array_key_last/array_find_key return null iff the + // array has no matching key - a null constraint on the call narrows + // the array argument (both directions for first/last, non-empty only + // for find_key: an empty result does not mean an empty array) + if ( + $expr->name instanceof Name + && !$expr->isFirstClassCallable() + && isset($expr->getArgs()[0]) + && $type->isNull()->yes() + ) { + $funcName = $expr->name->toLowerString(); + $bothDirections = in_array($funcName, ['array_key_first', 'array_key_last'], true); + if ($bothDirections || $funcName === 'array_find_key') { + $argExpr = $expr->getArgs()[0]->value; + $argType = $nodeScopeResolver->readTypeOfMaybeStored($argExpr, $s); + if ($argType->isArray()->yes() && ($bothDirections || $createContext->falsey())) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new NonEmptyArrayType(), $createContext->negate(), $s), + ); + } + } + } + + return $types; + }; // Store a preliminary result carrying the type/specify callbacks before the // throw-point return type is computed: getFunctionThrowPoint() resolves the diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 53a48eb2dde..5474cffdc1b 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -71,7 +71,7 @@ public function specifyIdentical( return $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); } - if (!$this->isSubjectCoveredAgainstConstant($subject)) { + if ($constantName !== 'null' && !$this->isSubjectCoveredAgainstConstant($subject)) { return null; } @@ -240,8 +240,10 @@ private function isScalarLiteral(Expr $expr): bool /** * Subjects whose comparison against a constant narrows more than the * subject expression itself stay on the old-world path for now: function - * calls narrow their arguments (array_key_first($a) !== null makes $a - * non-empty, count($a) === 0 empties $a), `$a::class` narrows $a. + * calls narrow their arguments (count($a) === 0 empties $a), `$a::class` + * narrows $a. The null comparison is fully composed (the array_key_first + * family narrows its argument through the FuncCall's createTypesCallback) + * and does not consult this. */ private function isSubjectCoveredAgainstConstant(Expr $subject): bool { diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 1f83d2b8d55..57d90e74933 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -275,6 +275,20 @@ public function funcCallAgainstNull(array $list): void assertType('non-empty-list', $list); assertType('string', $list[$key]); } + if (array_key_first($list) === null) { + assertType('array{}', $list); + } else { + assertType('non-empty-list', $list); + } + if (array_key_last($list) !== null) { + assertType('non-empty-list', $list); + } + if (array_find_key($list, static fn (string $v): bool => $v !== '') !== null) { + assertType('non-empty-list', $list); + } else { + // an empty find result does not mean an empty array + assertType('list', $list); + } } } From 955fa4d3ef4374e8f0766154de8683ea12c622d9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 22:09:35 +0200 Subject: [PATCH 248/378] Narrow get_class/get_debug_type identity comparisons in IdenticalNarrowingHelper A class-name literal compared against get_class/get_debug_type pins the call's argument to a final ObjectType in the true context; outside it only the call itself pins the constant, like any other scalar-literal subject. Other function-call subjects still fall back to the old-world path. --- .../Helper/IdenticalNarrowingHelper.php | 38 ++++++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 13 +++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 5474cffdc1b..52c57ecf044 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -11,8 +11,10 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\NullType; use PHPStan\Type\Type; use function count; @@ -35,6 +37,7 @@ final class IdenticalNarrowingHelper public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, + private ReflectionProvider $reflectionProvider, ) { } @@ -150,7 +153,19 @@ private function specifyAgainstScalarLiteral( return null; } - if (!$this->isSubjectCoveredAgainstConstant($subject)) { + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if ($unwrappedSubject instanceof Expr\FuncCall) { + // get_class/get_debug_type compose below; other calls narrow their + // arguments in ways not ported yet (count($a) === 0 empties $a) + if ( + !($unwrappedSubject->name instanceof Name) + || $unwrappedSubject->isFirstClassCallable() + || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type'], true) + || !isset($unwrappedSubject->getArgs()[0]) + ) { + return null; + } + } elseif (!$this->isSubjectCoveredAgainstConstant($subject)) { return null; } @@ -165,6 +180,27 @@ private function specifyAgainstScalarLiteral( return null; } + // get_class($o) === 'Foo' pins $o to a final Foo when the comparison + // holds; outside the true context only the call itself narrows + if ($unwrappedSubject instanceof Expr\FuncCall && $context->true()) { + $narrowedObjectType = null; + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + $narrowedObjectType = new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()); + } elseif ($constantType->getClassStringObjectType()->isObject()->yes()) { + $narrowedObjectType = $constantType->getClassStringObjectType(); + } + + if ($narrowedObjectType !== null) { + return $this->defaultNarrowingHelper->createForSubject( + $unwrappedSubject->getArgs()[0]->value, + $narrowedObjectType, + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + } + $types = $this->defaultNarrowingHelper->createSubjectTypes( $evaluationScope, $subject, diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 57d90e74933..6ec18bc0c36 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -164,6 +164,19 @@ public function getClassNarrowing(object $o): void { if (get_class($o) === Foo::class) { assertType('EqualityNarrowingNewWorld\Foo', $o); + } else { + assertType('object', $o); + } + if (Foo::class === get_class($o)) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_debug_type($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_class($o) !== Foo::class) { + assertType('object', $o); + } else { + assertType('EqualityNarrowingNewWorld\Foo', $o); } } From b200b575a8011f5a97194ab41b79f05923f7488a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 22:20:07 +0200 Subject: [PATCH 249/378] Narrow gettype identity comparisons in IdenticalNarrowingHelper A type-name string compared against gettype() narrows the argument by the named type in either direction. The composition runs before the decided-comparison guard, like the old block - an exhaustive switch (gettype($y)) must still subtract the last case's type on the way to the default arm so it collapses to never. Unknown type-name strings only pin the call itself; sides with more than one constant string fall back to the old-world path. --- .../Helper/IdenticalNarrowingHelper.php | 69 +++++++++++++++++-- .../nsrt/equality-narrowing-new-world.php | 24 +++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 52c57ecf044..3e2a405e27f 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -12,9 +12,17 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\ArrayType; +use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\FloatType; +use PHPStan\Type\IntegerType; +use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\ResourceType; +use PHPStan\Type\StringType; use PHPStan\Type\NullType; use PHPStan\Type\Type; use function count; @@ -160,7 +168,7 @@ private function specifyAgainstScalarLiteral( if ( !($unwrappedSubject->name instanceof Name) || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type'], true) + || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype'], true) || !isset($unwrappedSubject->getArgs()[0]) ) { return null; @@ -169,17 +177,36 @@ private function specifyAgainstScalarLiteral( return null; } - $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); - if ($decidedTypes !== null) { - return $decidedTypes; - } - $constantType = $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); if (count($constantType->getFiniteTypes()) !== 1) { // a class constant does not have to be single-valued return null; } + // gettype($x) === 'string' narrows $x by the named type in either + // direction - before the decided-comparison guard, like the old block + if ( + $unwrappedSubject instanceof Expr\FuncCall + && $unwrappedSubject->name->toLowerString() === 'gettype' + ) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) !== 1) { + return null; + } + $gettypeNarrowedType = $this->getTypeFromGettypeStringValue($constantStrings[0]->getValue()); + if ($gettypeNarrowedType !== null) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($unwrappedSubject->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), + ); + } + // an unknown type-name string only pins the call itself below + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + // get_class($o) === 'Foo' pins $o to a final Foo when the comparison // holds; outside the true context only the call itself narrows if ($unwrappedSubject instanceof Expr\FuncCall && $context->true()) { @@ -260,6 +287,36 @@ private function specifyDecidedComparison( ); } + private function getTypeFromGettypeStringValue(string $value): ?Type + { + if ($value === 'string') { + return new StringType(); + } + if ($value === 'array') { + return new ArrayType(new MixedType(), new MixedType()); + } + if ($value === 'boolean') { + return new BooleanType(); + } + if (in_array($value, ['resource', 'resource (closed)'], true)) { + return new ResourceType(); + } + if ($value === 'integer') { + return new IntegerType(); + } + if ($value === 'double') { + return new FloatType(); + } + if ($value === 'NULL') { + return new NullType(); + } + if ($value === 'object') { + return new ObjectWithoutClassType(); + } + + return null; + } + private function isScalarLiteral(Expr $expr): bool { if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\String_ || $expr instanceof Scalar\Float_) { diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 6ec18bc0c36..589e83b0419 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -180,6 +180,30 @@ public function getClassNarrowing(object $o): void } } + /** + * @param int|string $is + * @param mixed $m + */ + public function gettypeNarrowing($is, $m): void + { + if (gettype($is) === 'string') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if (gettype($m) === 'NULL') { + assertType('null', $m); + } + if (gettype($is) !== 'integer') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if (gettype($m) === 'double') { + assertType('float', $m); + } + } + /** @param mixed $m */ public function looseEquality($m, ?string $s): void { From 0d5a1a6c0001788669775fd9c6982a07bd157045 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 4 Jul 2026 22:34:31 +0200 Subject: [PATCH 250/378] Narrow preg_match identity comparisons in IdenticalNarrowingHelper preg_match(...) === 1 in the true context delegates to the call's own truthy narrowing, so the type-specifying extensions narrow the by-ref $matches argument. Other constants and contexts pin the call like any scalar-literal subject. --- .../Helper/IdenticalNarrowingHelper.php | 16 +++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 13 +++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 3e2a405e27f..ca8af6d7315 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -15,6 +15,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; @@ -168,7 +169,7 @@ private function specifyAgainstScalarLiteral( if ( !($unwrappedSubject->name instanceof Name) || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype'], true) + || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match'], true) || !isset($unwrappedSubject->getArgs()[0]) ) { return null; @@ -183,6 +184,19 @@ private function specifyAgainstScalarLiteral( return null; } + // preg_match(...) === 1 is the call's own truthy narrowing - the + // type-specifying extensions narrow the by-ref \$matches argument + if ( + $unwrappedSubject instanceof Expr\FuncCall + && $unwrappedSubject->name->toLowerString() === 'preg_match' + ) { + if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context); + } + + // other constants and contexts only pin the call below + } + // gettype($x) === 'string' narrows $x by the named type in either // direction - before the decided-comparison guard, like the old block if ( diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 589e83b0419..445ff95313d 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -204,6 +204,19 @@ public function gettypeNarrowing($is, $m): void } } + public function pregMatchNarrowing(string $s): void + { + if (preg_match('/^a(b)c$/', $s, $matches) === 1) { + assertType("array{non-falsy-string, 'b'}", $matches); + } + if (1 === preg_match('/^a(b)c$/', $s, $matches2)) { + assertType("array{non-falsy-string, 'b'}", $matches2); + } + if (preg_match('/^a(b)c$/', $s, $matches3) === 0) { + assertType("array{}|array{non-falsy-string, 'b'}", $matches3); + } + } + /** @param mixed $m */ public function looseEquality($m, ?string $s): void { From 813a75c814e7296e0043a20e8896a0cebc0b460a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 08:47:23 +0200 Subject: [PATCH 251/378] Narrow strlen/mb_strlen identity comparisons in IdenticalNarrowingHelper strlen($x) === 0 pins the empty string on the argument; a truthy size of one or more adds the non-empty (two or more: non-falsy) string accessory when the argument is a string. Non-string arguments and falsey non-zero sizes only pin the call; negative and non-literal sizes fall back to the old-world path. --- .../Helper/IdenticalNarrowingHelper.php | 44 ++++++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 11 +++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index ca8af6d7315..32be927d37d 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -12,19 +12,23 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FloatType; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\ResourceType; use PHPStan\Type\StringType; -use PHPStan\Type\NullType; use PHPStan\Type\Type; use function count; use function in_array; @@ -169,7 +173,7 @@ private function specifyAgainstScalarLiteral( if ( !($unwrappedSubject->name instanceof Name) || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match'], true) + || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen'], true) || !isset($unwrappedSubject->getArgs()[0]) ) { return null; @@ -197,6 +201,42 @@ private function specifyAgainstScalarLiteral( // other constants and contexts only pin the call below } + // strlen($x) === 0 empties $x; === N >= 1 makes it non-empty in the + // truthy direction (>= 2 non-falsy) - before the decided guard + if ( + $unwrappedSubject instanceof Expr\FuncCall + && in_array($unwrappedSubject->name->toLowerString(), ['strlen', 'mb_strlen'], true) + ) { + if (count($unwrappedSubject->getArgs()) !== 1 || !$constantType->isInteger()->yes()) { + return null; + } + + $argExpr = $unwrappedSubject->getArgs()[0]->value; + if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new ConstantStringType(''), $context, $evaluationScope), + ); + } + + if ($context->truthy()) { + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); + if ($argResult === null) { + return null; + } + if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + $accessory = IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($constantType)->yes() + ? new AccessoryNonFalsyStringType() + : new AccessoryNonEmptyStringType(); + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, $accessory, $context, $evaluationScope), + ); + } + } + + // a non-string argument or a falsey non-zero size only pins the call + } + // gettype($x) === 'string' narrows $x by the named type in either // direction - before the decided-comparison guard, like the old block if ( diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 445ff95313d..fbe2e51089f 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -158,6 +158,17 @@ public function strlenNarrowing(string $s): void if (strlen($s) !== 0) { assertType('non-empty-string', $s); } + if (strlen($s) === 1) { + assertType('non-empty-string', $s); + } + if (strlen($s) === 2) { + assertType('non-falsy-string', $s); + } + if (mb_strlen($s) === 0) { + assertType("''", $s); + } else { + assertType('non-empty-string', $s); + } } public function getClassNarrowing(object $o): void From ce8ad99e76b39779f37460c283b5e0662d38255b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:24:20 +0200 Subject: [PATCH 252/378] Move count-size narrowing into CountNarrowingHelper The former TypeSpecifier::specifyTypesForCountFuncCall() and isNormalCountCall(), composed through createForSubject() instead of TypeSpecifier::create(). BinaryOpHandler's count-comparison path and the equality helper now use the new helper; TypeSpecifier loses both @internal methods. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 8 +- .../Helper/CountNarrowingHelper.php | 180 ++++++++++++++++++ .../Helper/EqualityTypeSpecifyingHelper.php | 7 +- src/Analyser/TypeSpecifier.php | 138 -------------- 4 files changed, 187 insertions(+), 146 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index d848b53cf31..2be9937adab 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CountNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; @@ -27,7 +28,6 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -70,8 +70,8 @@ public function __construct( private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, private IdenticalNarrowingHelper $identicalNarrowingHelper, + private CountNarrowingHelper $countNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -409,7 +409,7 @@ static function () use ($nodeScopeResolver, $expr, $scope): Type { } if ($sizeType !== null) { - $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($expr->right, $argType, $sizeType, $context, $scope, $expr); if ($specifiedTypes !== null) { $result = $result->unionWith($specifiedTypes); } @@ -492,7 +492,7 @@ static function () use ($nodeScopeResolver, $expr, $scope): Type { $subtractedType = $getType($expr->right->right); if ( $countArgType->isList()->yes() - && $this->typeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() + && $this->countNarrowingHelper->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() ) { $arrayArg = $expr->right->left->getArgs()[0]->value; diff --git a/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php new file mode 100644 index 00000000000..7888a17893b --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php @@ -0,0 +1,180 @@ +getArgs()) === 1) { + return TrinaryLogic::createYes(); + } + + $modeArg = $countFuncCall->getArgs()[1]->value; + $mode = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($modeArg)?->getTypeOnScope($scope, $scope->nativeTypesPromoted) + ?? $scope->getType($modeArg); + + return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); + } + + public function specifyCountSize( + FuncCall $countFuncCall, + Type $type, + Type $sizeType, + TypeSpecifierContext $context, + MutatingScope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + $isConstantArray = $type->isConstantArray(); + $isList = $type->isList(); + $oneOrMore = IntegerRangeType::fromInterval(1, null); + if ( + !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() + || (!$isConstantArray->yes() && !$isList->yes()) + || !$oneOrMore->isSuperTypeOf($sizeType)->yes() + || $sizeType->isSuperTypeOf($type->getArraySize())->yes() + ) { + return null; + } + + if ($context->falsey() && $isConstantArray->yes()) { + $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); + if (!$remainingSize instanceof NeverType) { + $negatedContext = $context->false() + ? TypeSpecifierContext::createTrue() + : TypeSpecifierContext::createTruthy(); + $result = $this->specifyCountSize( + $countFuncCall, + $type, + $remainingSize, + $negatedContext, + $scope, + $rootExpr, + ); + if ($result !== null) { + return $result; + } + } + + // Fallback: directly filter constant arrays by their exact sizes. + // This avoids using TypeCombinator::remove() with falsey context, + // which can incorrectly remove arrays whose count doesn't match + // but whose shape is a subtype of the matched array. + $keptTypes = []; + foreach ($type->getConstantArrays() as $arrayType) { + if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { + continue; + } + + $keptTypes[] = $arrayType; + } + if ($keptTypes !== []) { + return $this->defaultNarrowingHelper->createForSubject( + $countFuncCall->getArgs()[0]->value, + TypeCombinator::union(...$keptTypes), + $context->negate(), + $scope, + )->setRootExpr($rootExpr); + } + } + + $resultTypes = []; + foreach ($type->getArrays() as $arrayType) { + $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); + if ($isSizeSuperTypeOfArraySize->no()) { + continue; + } + + if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { + continue; + } + + $resultTypes[] = $isList->yes() + ? $arrayType->truncateListToSize($sizeType) + : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { + $hasOptionalKeysOrUnsealed = false; + foreach ($type->getConstantArrays() as $arrayType) { + if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { + // Unsealed CATs can't be narrowed via the + // `HasOffsetValueType`-only shortcut below — the + // intersection of an unsealed shape with a single-slot + // constraint produces `NeverType`. Fall through to + // the full builder-based narrowing, which carries the + // unsealed slot via the loop above. + $hasOptionalKeysOrUnsealed = true; + break; + } + } + + if (!$hasOptionalKeysOrUnsealed) { + $argExpr = $countFuncCall->getArgs()[0]->value; + $argExprString = $this->exprPrinter->printExpr($argExpr); + + $sizeMin = null; + $sizeMax = null; + if ($sizeType instanceof ConstantIntegerType) { + $sizeMin = $sizeType->getValue(); + $sizeMax = $sizeType->getValue(); + } elseif ($sizeType instanceof IntegerRangeType) { + $sizeMin = $sizeType->getMin(); + $sizeMax = $sizeType->getMax(); + } + + $sureTypes = []; + $sureNotTypes = []; + + if ($sizeMin !== null && $sizeMin >= 1) { + $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; + } + if ($sizeMax !== null) { + $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; + } + + if ($sureTypes !== [] || $sureNotTypes !== []) { + return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); + } + } + } + + return $this->defaultNarrowingHelper->createForSubject($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index 154f5bc6f93..ead819fdc6a 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -15,7 +15,6 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -63,10 +62,10 @@ final class EqualityTypeSpecifyingHelper { public function __construct( - private TypeSpecifier $typeSpecifier, private ReflectionProvider $reflectionProvider, private ExprPrinter $exprPrinter, private DefaultNarrowingHelper $defaultNarrowingHelper, + private CountNarrowingHelper $countNarrowingHelper, ) { } @@ -336,7 +335,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope $argType = $getType($unwrappedRightExpr->getArgs()[0]->value); $sizeType = $getType($leftExpr); - $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); + $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedRightExpr, $argType, $sizeType, $context, $scope->toMutatingScope(), $expr); if ($specifiedTypes !== null) { return $specifiedTypes; } @@ -379,7 +378,7 @@ private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScope ); } - $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); + $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedLeftExpr, $argType, $rightType, $context, $scope->toMutatingScope(), $expr); if ($specifiedTypes !== null) { if ($leftExpr !== $unwrappedLeftExpr) { $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9e1ac73d6a1..3be7b231478 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -98,144 +98,6 @@ public function specifyTypesInCondition( return $this->specifyDefaultTypes($scope, $expr, $context); } - /** @internal */ - public function isNormalCountCall(FuncCall $countFuncCall, Type $typeToCount, Scope $scope): TrinaryLogic - { - if (count($countFuncCall->getArgs()) === 1) { - return TrinaryLogic::createYes(); - } - - $mode = $scope->getType($countFuncCall->getArgs()[1]->value); - return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); - } - - /** @internal */ - public function specifyTypesForCountFuncCall( - FuncCall $countFuncCall, - Type $type, - Type $sizeType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes - { - $isConstantArray = $type->isConstantArray(); - $isList = $type->isList(); - $oneOrMore = IntegerRangeType::fromInterval(1, null); - if ( - !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() - || (!$isConstantArray->yes() && !$isList->yes()) - || !$oneOrMore->isSuperTypeOf($sizeType)->yes() - || $sizeType->isSuperTypeOf($type->getArraySize())->yes() - ) { - return null; - } - - if ($context->falsey() && $isConstantArray->yes()) { - $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); - if (!$remainingSize instanceof NeverType) { - $negatedContext = $context->false() - ? TypeSpecifierContext::createTrue() - : TypeSpecifierContext::createTruthy(); - $result = $this->specifyTypesForCountFuncCall( - $countFuncCall, - $type, - $remainingSize, - $negatedContext, - $scope, - $rootExpr, - ); - if ($result !== null) { - return $result; - } - } - - // Fallback: directly filter constant arrays by their exact sizes. - // This avoids using TypeCombinator::remove() with falsey context, - // which can incorrectly remove arrays whose count doesn't match - // but whose shape is a subtype of the matched array. - $keptTypes = []; - foreach ($type->getConstantArrays() as $arrayType) { - if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { - continue; - } - - $keptTypes[] = $arrayType; - } - if ($keptTypes !== []) { - return $this->create( - $countFuncCall->getArgs()[0]->value, - TypeCombinator::union(...$keptTypes), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); - } - } - - $resultTypes = []; - foreach ($type->getArrays() as $arrayType) { - $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); - if ($isSizeSuperTypeOfArraySize->no()) { - continue; - } - - if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { - continue; - } - - $resultTypes[] = $isList->yes() - ? $arrayType->truncateListToSize($sizeType) - : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); - } - - if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { - $hasOptionalKeysOrUnsealed = false; - foreach ($type->getConstantArrays() as $arrayType) { - if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { - // Unsealed CATs can't be narrowed via the - // `HasOffsetValueType`-only shortcut below — the - // intersection of an unsealed shape with a single-slot - // constraint produces `NeverType`. Fall through to - // the full builder-based narrowing, which carries the - // unsealed slot via the loop above. - $hasOptionalKeysOrUnsealed = true; - break; - } - } - - if (!$hasOptionalKeysOrUnsealed) { - $argExpr = $countFuncCall->getArgs()[0]->value; - $argExprString = $this->exprPrinter->printExpr($argExpr); - - $sizeMin = null; - $sizeMax = null; - if ($sizeType instanceof ConstantIntegerType) { - $sizeMin = $sizeType->getValue(); - $sizeMax = $sizeType->getValue(); - } elseif ($sizeType instanceof IntegerRangeType) { - $sizeMin = $sizeType->getMin(); - $sizeMax = $sizeType->getMax(); - } - - $sureTypes = []; - $sureNotTypes = []; - - if ($sizeMin !== null && $sizeMin >= 1) { - $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; - } - if ($sizeMax !== null) { - $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; - } - - if ($sureTypes !== [] || $sureNotTypes !== []) { - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); - } - } - } - - return $this->create($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); - } - /** * Fallback used by ExprHandler::specifyTypes implementations that have no * Expr-specific narrowing: applies the default truthy/falsey narrowing, or From 9b76f3fb1ac0f006559b31b930cf30faca1f713a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:30:29 +0200 Subject: [PATCH 253/378] Narrow count/sizeof identity comparisons in IdenticalNarrowingHelper A literal size of zero empties the argument (Countable|array{} when the truthy argument may not be an array); other literal sizes reconstruct the array shape through CountNarrowingHelper or add the non-empty accessory. Count-vs-count, negative and non-literal sizes fall back to the old-world path. --- .../Helper/IdenticalNarrowingHelper.php | 59 ++++++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 29 +++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 32be927d37d..73a072516f7 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use Countable; use PhpParser\Node\Expr; use PhpParser\Node\Name; use PhpParser\Node\Scalar; @@ -16,6 +17,8 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\Accessory\NonEmptyArrayType; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -30,6 +33,7 @@ use PHPStan\Type\ResourceType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\UnionType; use function count; use function in_array; @@ -51,6 +55,7 @@ final class IdenticalNarrowingHelper public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, private ReflectionProvider $reflectionProvider, + private CountNarrowingHelper $countNarrowingHelper, ) { } @@ -173,7 +178,7 @@ private function specifyAgainstScalarLiteral( if ( !($unwrappedSubject->name instanceof Name) || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen'], true) + || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen', 'count', 'sizeof'], true) || !isset($unwrappedSubject->getArgs()[0]) ) { return null; @@ -201,6 +206,58 @@ private function specifyAgainstScalarLiteral( // other constants and contexts only pin the call below } + // count($x) === N reconstructs the array shape by its size - before + // the decided guard so exhaustive size switches keep collapsing + if ( + $unwrappedSubject instanceof Expr\FuncCall + && in_array($unwrappedSubject->name->toLowerString(), ['count', 'sizeof'], true) + ) { + if (!$constantType->isInteger()->yes()) { + return null; + } + + $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { + $newArgType = $context->truthy() && !$argType->isArray()->yes() + ? new UnionType([new ObjectType(Countable::class), new ConstantArrayType([], [])]) + : new ConstantArrayType([], []); + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, $newArgType, $context, $evaluationScope), + ); + } + + $countTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedSubject, $argType, $constantType, $context, $evaluationScope, $unwrappedSubject); + if ($countTypes !== null) { + // the old path pinned the call only through the remembered + // wrapper; the composed pin covers wrapper and call alike + if ($subject !== $unwrappedSubject) { + return $countTypes->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + + return $countTypes; + } + + if ($context->truthy() && $argType->isArray()->yes()) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context); + if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { + return $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new NonEmptyArrayType(), $context, $evaluationScope), + ); + } + + return $types; + } + + // a non-array argument in a non-truthy context only pins the call + } + // strlen($x) === 0 empties $x; === N >= 1 makes it non-empty in the // truthy direction (>= 2 non-falsy) - before the decided guard if ( diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index fbe2e51089f..331adedee67 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -148,6 +148,35 @@ public function countNarrowing(array $arr): void } } + /** + * @param list $list + * @param array{a: int, b?: string} $shape + * @param array $ints + */ + public function countNarrowingShapes(array $list, array $shape, array $ints): void + { + if (count($list) === 2) { + assertType('array{string, string}', $list); + } else { + assertType('list', $list); + } + if (count($list) !== 1) { + assertType('list', $list); + } else { + assertType('array{string}', $list); + } + if (count($shape) === 1) { + assertType('array{a: int, b?: string}', $shape); + } + if (sizeof($ints) === 0) { + assertType('array{}', $ints); + } + if (count($list, COUNT_RECURSIVE) === 2) { + // non-nested list: recursive count equals normal count + assertType('array{string, string}', $list); + } + } + public function strlenNarrowing(string $s): void { if (strlen($s) === 0) { From 66d82f8fff045a054ae42a3b9d6cfb4a4fe76449 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:35:01 +0200 Subject: [PATCH 254/378] Narrow string-function identity comparisons in IdenticalNarrowingHelper A non-empty literal result of the substr/strtolower family means the string argument was non-empty (non-falsy for a non-falsy literal); case-mapping functions pin the case accessory on the literal side. --- .../Helper/IdenticalNarrowingHelper.php | 55 ++++++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 18 ++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 73a072516f7..6340e0afbfb 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -13,10 +13,12 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -33,6 +35,7 @@ use PHPStan\Type\ResourceType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use function count; use function in_array; @@ -178,7 +181,12 @@ private function specifyAgainstScalarLiteral( if ( !($unwrappedSubject->name instanceof Name) || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), ['get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen', 'count', 'sizeof'], true) + || !in_array($unwrappedSubject->name->toLowerString(), [ + 'get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen', 'count', 'sizeof', + 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', + 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', + 'ucwords', 'mb_convert_case', 'mb_convert_kana', + ], true) || !isset($unwrappedSubject->getArgs()[0]) ) { return null; @@ -206,6 +214,51 @@ private function specifyAgainstScalarLiteral( // other constants and contexts only pin the call below } + // a string function whose result is a non-empty literal had a + // non-empty (non-falsy for a non-falsy literal) string argument; + // case-mapping functions pin the case accessory on the literal side + if ( + $unwrappedSubject instanceof Expr\FuncCall + && in_array($unwrappedSubject->name->toLowerString(), [ + 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', + 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', + 'ucwords', 'mb_convert_case', 'mb_convert_kana', + ], true) + ) { + if ($context->truthy() && $constantType->isNonEmptyString()->yes()) { + $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + if ($argType->isString()->yes()) { + $types = new SpecifiedTypes(); + $funcName = $unwrappedSubject->name->toLowerString(); + if (in_array($funcName, ['strtolower', 'mb_strtolower'], true)) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $constantExpr, $constantResult, TypeCombinator::intersect($constantType, new AccessoryLowercaseStringType()), $context); + } elseif (in_array($funcName, ['strtoupper', 'mb_strtoupper'], true)) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $constantExpr, $constantResult, TypeCombinator::intersect($constantType, new AccessoryUppercaseStringType()), $context); + } + + $accessory = $constantType->isNonFalsyString()->yes() + ? new AccessoryNonFalsyStringType() + : new AccessoryNonEmptyStringType(); + + return $types->unionWith($this->defaultNarrowingHelper->createForSubject( + $argExpr, + TypeCombinator::intersect($argType, $accessory), + $context, + $evaluationScope, + )); + } + } + + // a non-string argument, an empty literal or a non-truthy + // context only pins the call + } + // count($x) === N reconstructs the array shape by its size - before // the decided guard so exhaustive size switches keep collapsing if ( diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 331adedee67..7bb9b1fd87a 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -200,6 +200,24 @@ public function strlenNarrowing(string $s): void } } + public function substrFamilyNarrowing(string $s): void + { + if (substr($s, 0, 3) === 'foo') { + assertType('non-falsy-string', $s); + } + if (strtolower($s) === 'abc') { + assertType('non-falsy-string', $s); + } + if (strtoupper($s) === '0') { + assertType('non-empty-string', $s); + } + if (ucfirst($s) === 'Foo') { + assertType('non-falsy-string', $s); + } else { + assertType('string', $s); + } + } + public function getClassNarrowing(object $o): void { if (get_class($o) === Foo::class) { From 054509ef8526f7e87e5b65c9e9725fa1d296233f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:39:28 +0200 Subject: [PATCH 255/378] Narrow trim and get_parent_class identity comparisons in IdenticalNarrowingHelper A trimmed string that is not '' was a non-empty string already. A known parent class name narrows the argument to class-string, Parent, or their union, by the argument's stringness. --- .../Helper/IdenticalNarrowingHelper.php | 63 +++++++++++++++++++ .../nsrt/equality-narrowing-new-world.php | 19 ++++++ 2 files changed, 82 insertions(+) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 6340e0afbfb..98c6cf24767 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -26,7 +26,9 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\IntegerType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; @@ -186,6 +188,8 @@ private function specifyAgainstScalarLiteral( 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', 'ucwords', 'mb_convert_case', 'mb_convert_kana', + 'trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim', + 'get_parent_class', ], true) || !isset($unwrappedSubject->getArgs()[0]) ) { @@ -214,6 +218,65 @@ private function specifyAgainstScalarLiteral( // other constants and contexts only pin the call below } + // a trimmed string that is not '' was a non-empty string already + if ( + $unwrappedSubject instanceof Expr\FuncCall + && in_array($unwrappedSubject->name->toLowerString(), ['trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim'], true) + ) { + if ($context->false()) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { + $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); + if ($argResult === null) { + return null; + } + if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + return $this->defaultNarrowingHelper->createForSubject( + $argExpr, + new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]), + $context->negate(), + $evaluationScope, + ); + } + } + } + + // other constants and contexts only pin the call + } + + // a known parent class narrows the argument to the child side of it + if ( + $unwrappedSubject instanceof Expr\FuncCall + && $unwrappedSubject->name->toLowerString() === 'get_parent_class' + ) { + if ($context->true()) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { + $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $objectType = new ObjectType($constantStrings[0]->getValue()); + $classStringType = new GenericClassStringType($objectType); + + if ($argType->isString()->yes()) { + $narrowed = $classStringType; + } elseif ($argType->isObject()->yes()) { + $narrowed = $objectType; + } else { + $narrowed = TypeCombinator::union($objectType, $classStringType); + } + + return $this->defaultNarrowingHelper->createForSubject($argExpr, $narrowed, $context, $evaluationScope); + } + } + + return null; + } + // a string function whose result is a non-empty literal had a // non-empty (non-falsy for a non-falsy literal) string argument; // case-mapping functions pin the case accessory on the literal side diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 7bb9b1fd87a..a0797de6dc5 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -218,6 +218,25 @@ public function substrFamilyNarrowing(string $s): void } } + /** @param mixed $m */ + public function trimAndParentClass(string $s, object $o, $m): void + { + if (trim($s) !== '') { + assertType('non-empty-string', $s); + } + if (ltrim($s) === '') { + assertType('string', $s); + } else { + assertType('non-empty-string', $s); + } + if (get_parent_class($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_parent_class($m) === Foo::class) { + assertType('class-string|EqualityNarrowingNewWorld\Foo', $m); + } + } + public function getClassNarrowing(object $o): void { if (get_class($o) === Foo::class) { From 9397f76322720494f9b79036eb9b82889d12411c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:46:00 +0200 Subject: [PATCH 256/378] Narrow ::class identity comparisons in IdenticalNarrowingHelper $a::class === Foo::class narrows $a to a final Foo in the true context. Plain-string sides, unknown class names (which narrow like instanceof) and other contexts pin the fetch or fall back to the old-world path. --- .../Helper/IdenticalNarrowingHelper.php | 29 +++++++++++++++++++ .../nsrt/equality-narrowing-new-world.php | 20 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 98c6cf24767..eae53c20bc6 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -195,6 +195,11 @@ private function specifyAgainstScalarLiteral( ) { return null; } + } elseif ($unwrappedSubject instanceof Expr\ClassConstFetch && $unwrappedSubject->class instanceof Expr) { + // only ::class composes; a constant fetched off an object falls back + if ($unwrappedSubject->name instanceof Expr || $unwrappedSubject->name->toLowerString() !== 'class') { + return null; + } } elseif (!$this->isSubjectCoveredAgainstConstant($subject)) { return null; } @@ -218,6 +223,30 @@ private function specifyAgainstScalarLiteral( // other constants and contexts only pin the call below } + // $a::class === Foo::class narrows $a to a final Foo when true; + // other contexts and plain-string sides only pin the fetch + if ( + $unwrappedSubject instanceof Expr\ClassConstFetch + && $unwrappedSubject->class instanceof Expr + && $context->true() + && $constantExpr instanceof Expr\ClassConstFetch + ) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { + if (!$this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + // an unknown class name narrows like instanceof - not composed yet + return null; + } + + return $this->defaultNarrowingHelper->createForSubject( + $unwrappedSubject->class, + new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + } + // a trimmed string that is not '' was a non-empty string already if ( $unwrappedSubject instanceof Expr\FuncCall diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index a0797de6dc5..2490f027044 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -294,6 +294,26 @@ public function pregMatchNarrowing(string $s): void } } + public function classConstFetchNarrowing(object $o): void + { + if ($o::class === Foo::class) { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } else { + assertType('object', $o); + } + if (Foo::class === $o::class) { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } + if ($o::class !== Foo::class) { + assertType('object', $o); + } else { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } + if ($o::class === 'EqualityNarrowingNewWorld\\Foo') { + assertType('object', $o); + } + } + /** @param mixed $m */ public function looseEquality($m, ?string $s): void { From a33367f6d6979a6e76ef47fff5c5b431cefe4b3f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 12:52:00 +0200 Subject: [PATCH 257/378] Narrow general identity comparisons in IdenticalNarrowingHelper The expr-vs-expr tail, composed from the operand results: a single-valued side pins its value onto the other, a truthy comparison pins both sides with each other's types, a false one cross-excludes through normalize+intersect. Calls, ::class fetches and sides whose TYPE is a constant bool (which delegate into bool-context narrowing) still fall back to the old-world path. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 1 + .../Helper/IdenticalNarrowingHelper.php | 109 +++++++++++++++++- .../nsrt/equality-narrowing-new-world.php | 30 +++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 2be9937adab..6cd335844d7 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -259,6 +259,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // no synthetic Identical node. A null context never negates. if (!($context->null() && $expr instanceof BinaryOp\NotIdentical)) { $newWorldTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, $expr->left, $expr->right, $leftResult, diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index eae53c20bc6..c77cc050ed9 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -8,10 +8,12 @@ use PhpParser\Node\Scalar; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; +use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -61,6 +63,7 @@ public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, private ReflectionProvider $reflectionProvider, private CountNarrowingHelper $countNarrowingHelper, + private ExprPrinter $exprPrinter, ) { } @@ -70,6 +73,7 @@ public function __construct( * in Identical semantics (the caller flips a NotIdentical verdict) */ public function specifyIdentical( + NodeScopeResolver $nodeScopeResolver, Expr $left, Expr $right, ExpressionResult $leftResult, @@ -94,7 +98,12 @@ public function specifyIdentical( $subject = $left; $subjectResult = $leftResult; } else { - return $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + $types = $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($types !== null) { + return $types; + } + + return $this->specifyGeneral($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); } if ($constantName !== 'null' && !$this->isSubjectCoveredAgainstConstant($subject)) { @@ -573,6 +582,104 @@ private function getTypeFromGettypeStringValue(string $value): ?Type return null; } + /** + * The general expr-vs-expr tail of the identical narrowing: a + * single-valued side pins its value onto the other, otherwise both sides + * pin each other's types in the true context and cross-exclude in the + * false one. Runs only for operand shapes whose specialized narrowing is + * already composed - calls and ::class fetches still fall back. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyGeneral( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + + // calls narrow their arguments and ::class fetches their class by + // TYPE-based constants too - those old-world blocks are not composed + foreach ([$unwrappedLeft, $unwrappedRight] as $side) { + if ($side instanceof Expr\FuncCall) { + return null; + } + if ($side instanceof Expr\ClassConstFetch && $side->class instanceof Expr) { + return null; + } + } + + $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + // a side whose TYPE is a constant bool delegates into the subject's + // own bool-context narrowing - only the literal form is composed + foreach ([$leftType, $rightType] as $sideType) { + if ($sideType->isTrue()->yes() || $sideType->isFalse()->yes()) { + return null; + } + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $types = null; + if ( + count($leftType->getFiniteTypes()) === 1 + || ( + $context->true() + && $leftType->isConstantValue()->yes() + && !$rightType->equals($leftType) + && $rightType->isSuperTypeOf($leftType)->yes()) + ) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context); + } + if ( + count($rightType->getFiniteTypes()) === 1 + || ( + $context->true() + && $rightType->isConstantValue()->yes() + && !$leftType->equals($rightType) + && $leftType->isSuperTypeOf($rightType)->yes() + ) + ) { + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context); + $types = $types !== null ? $types->unionWith($leftTypes) : $leftTypes; + } + + if ($types !== null) { + return $types; + } + + $leftExprString = $this->exprPrinter->printExpr($unwrappedLeft); + $rightExprString = $this->exprPrinter->printExpr($unwrappedRight); + if ($leftExprString === $rightExprString) { + if (!$unwrappedLeft instanceof Expr\Variable || !$unwrappedRight instanceof Expr\Variable) { + return new SpecifiedTypes([], []); + } + } + + if ($context->true()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context), + ); + } elseif ($context->false()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context)->normalize($evaluationScope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context)->normalize($evaluationScope, $nodeScopeResolver)); + } + + return new SpecifiedTypes([], []); + } + private function isScalarLiteral(Expr $expr): bool { if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\String_ || $expr instanceof Scalar\Float_) { diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 2490f027044..917526331c5 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -294,6 +294,36 @@ public function pregMatchNarrowing(string $s): void } } + /** + * @param 5 $five + * @param int|string $is + * @param Suit $suit + * @param Suit $otherSuit + * @param array{a: int}|null $arrOrNull + * @param array{a: int}|false $arrOrFalse + */ + public function generalExprVsExpr($five, $is, Suit $suit, Suit $otherSuit, $arrOrNull, $arrOrFalse, ?int $ni): void + { + if ($is === $five) { + assertType('5', $is); + } else { + assertType('int|int<6, max>|string', $is); + } + if ($suit === $otherSuit) { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } else { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } + if ($arrOrNull === $ni) { + assertType('null', $arrOrNull); + assertType('null', $ni); + } + if ($arrOrFalse === $arrOrNull) { + assertType('array{a: int}', $arrOrFalse); + assertType('array{a: int}', $arrOrNull); + } + } + public function classConstFetchNarrowing(object $o): void { if ($o::class === Foo::class) { From a9cd707b331c6a7282322001bf3ecbdc448ac056 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 13:08:44 +0200 Subject: [PATCH 258/378] Compose function-family narrowing for type-based constant sides The family compositions move into specifyFuncCallFamilies(), shared by literal sides and by a single call side taking the other side's TYPE as the constant - the composed form of the old normalization that moved the call to the left. Adds the negative-size NeverType branches and strlen's complete size>=1 gate, which literal sides never exercised. Fixes a TypeSpecifierTest known-red: the array-vs-array count comparison in a BooleanAnd now expects no narrowing, matching the data set. --- .../Helper/IdenticalNarrowingHelper.php | 519 ++++++++++-------- .../nsrt/equality-narrowing-new-world.php | 25 + 2 files changed, 302 insertions(+), 242 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index c77cc050ed9..2f50a6d84bb 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -187,23 +187,13 @@ private function specifyAgainstScalarLiteral( $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if ($unwrappedSubject instanceof Expr\FuncCall) { - // get_class/get_debug_type compose below; other calls narrow their - // arguments in ways not ported yet (count($a) === 0 empties $a) - if ( - !($unwrappedSubject->name instanceof Name) - || $unwrappedSubject->isFirstClassCallable() - || !in_array($unwrappedSubject->name->toLowerString(), [ - 'get_class', 'get_debug_type', 'gettype', 'preg_match', 'strlen', 'mb_strlen', 'count', 'sizeof', - 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', - 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', - 'ucwords', 'mb_convert_case', 'mb_convert_kana', - 'trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim', - 'get_parent_class', - ], true) - || !isset($unwrappedSubject->getArgs()[0]) - ) { + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantResult, $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); + if ($familyTypes === null) { return null; } + if ($familyTypes !== false) { + return $familyTypes; + } } elseif ($unwrappedSubject instanceof Expr\ClassConstFetch && $unwrappedSubject->class instanceof Expr) { // only ::class composes; a constant fetched off an object falls back if ($unwrappedSubject->name instanceof Expr || $unwrappedSubject->name->toLowerString() !== 'class') { @@ -219,19 +209,6 @@ private function specifyAgainstScalarLiteral( return null; } - // preg_match(...) === 1 is the call's own truthy narrowing - the - // type-specifying extensions narrow the by-ref \$matches argument - if ( - $unwrappedSubject instanceof Expr\FuncCall - && $unwrappedSubject->name->toLowerString() === 'preg_match' - ) { - if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { - return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context); - } - - // other constants and contexts only pin the call below - } - // $a::class === Foo::class narrows $a to a final Foo when true; // other contexts and plain-string sides only pin the fetch if ( @@ -256,15 +233,258 @@ private function specifyAgainstScalarLiteral( } } + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + $constantType, + $context, + ); + + // a single-valued subject pins its value onto the literal side too + $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($subjectType->getFiniteTypes()) === 1) { + $types = $types->unionWith($this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $constantExpr, + $constantResult, + $subjectType, + $context, + )); + } + + return $types; + } + + /** + * A statically decided comparison tells the false context nothing: the + * branch is dead on the certain flavour, and subtracting the constant + * would wrongly leak into the wider native flavour (mixed~'ab'). The + * NeverType entries mirror the old identical tail's no-op. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyDecidedComparison( + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if (!$context->false()) { + return null; + } + + $identicalType = $identicalTypeCallback(); + $isTrue = $identicalType->isTrue()->yes(); + if (!$isTrue && !$identicalType->isFalse()->yes()) { + return null; + } + + $never = new NeverType(); + $contextForTypes = $isTrue ? $context->negate() : $context; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $never, $contextForTypes)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $never, $contextForTypes), + ); + } + + private function getTypeFromGettypeStringValue(string $value): ?Type + { + if ($value === 'string') { + return new StringType(); + } + if ($value === 'array') { + return new ArrayType(new MixedType(), new MixedType()); + } + if ($value === 'boolean') { + return new BooleanType(); + } + if (in_array($value, ['resource', 'resource (closed)'], true)) { + return new ResourceType(); + } + if ($value === 'integer') { + return new IntegerType(); + } + if ($value === 'double') { + return new FloatType(); + } + if ($value === 'NULL') { + return new NullType(); + } + if ($value === 'object') { + return new ObjectWithoutClassType(); + } + + return null; + } + + /** + * The general expr-vs-expr tail of the identical narrowing: a + * single-valued side pins its value onto the other, otherwise both sides + * pin each other's types in the true context and cross-exclude in the + * false one. Runs only for operand shapes whose specialized narrowing is + * already composed - calls and ::class fetches still fall back. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyGeneral( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + + // fn1() === fn2() merges both normalized directions - not composed; + // ::class fetches narrow by TYPE-based constants in old-world blocks + if ($unwrappedLeft instanceof Expr\FuncCall && $unwrappedRight instanceof Expr\FuncCall) { + return null; + } + foreach ([$unwrappedLeft, $unwrappedRight] as $side) { + if ($side instanceof Expr\ClassConstFetch && $side->class instanceof Expr) { + return null; + } + } + + $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + // a side whose TYPE is a constant bool delegates into the subject's + // own bool-context narrowing - only the literal form is composed + foreach ([$leftType, $rightType] as $sideType) { + if ($sideType->isTrue()->yes() || $sideType->isFalse()->yes()) { + return null; + } + } + + // a single call side runs the family compositions with the other + // side's TYPE as the constant - the composed form of the old + // normalization that moved the call to the left + if ($unwrappedLeft instanceof Expr\FuncCall || $unwrappedRight instanceof Expr\FuncCall) { + if ($unwrappedLeft instanceof Expr\FuncCall) { + $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightResult, $rightType, $context, $evaluationScope); + } else { + $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftResult, $leftType, $context, $evaluationScope); + } + if ($familyTypes === null) { + return null; + } + if ($familyTypes !== false) { + return $familyTypes; + } + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $types = null; + if ( + count($leftType->getFiniteTypes()) === 1 + || ( + $context->true() + && $leftType->isConstantValue()->yes() + && !$rightType->equals($leftType) + && $rightType->isSuperTypeOf($leftType)->yes()) + ) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context); + } + if ( + count($rightType->getFiniteTypes()) === 1 + || ( + $context->true() + && $rightType->isConstantValue()->yes() + && !$leftType->equals($rightType) + && $leftType->isSuperTypeOf($rightType)->yes() + ) + ) { + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context); + $types = $types !== null ? $types->unionWith($leftTypes) : $leftTypes; + } + + if ($types !== null) { + return $types; + } + + $leftExprString = $this->exprPrinter->printExpr($unwrappedLeft); + $rightExprString = $this->exprPrinter->printExpr($unwrappedRight); + if ($leftExprString === $rightExprString) { + if (!$unwrappedLeft instanceof Expr\Variable || !$unwrappedRight instanceof Expr\Variable) { + return new SpecifiedTypes([], []); + } + } + + if ($context->true()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context), + ); + } elseif ($context->false()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context)->normalize($evaluationScope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context)->normalize($evaluationScope, $nodeScopeResolver)); + } + + return new SpecifiedTypes([], []); + } + + /** + * The function-family compositions, shared by the literal and the + * TYPE-based constant sides: a family answer, null to fall back to the + * old-world path, or false when no family matched and the caller narrows + * generically. + * + * @return SpecifiedTypes|false|null + */ + private function specifyFuncCallFamilies( + Expr $subject, + ExpressionResult $subjectResult, + Expr\FuncCall $call, + Expr $constantExpr, + ExpressionResult $constantResult, + Type $constantType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ): SpecifiedTypes|false|null + { + if (!($call->name instanceof Name) || $call->isFirstClassCallable() || !isset($call->getArgs()[0])) { + return false; + } + + // preg_match(...) === 1 is the call's own truthy narrowing - the + // type-specifying extensions narrow the by-ref \$matches argument + if ( + $call->name->toLowerString() === 'preg_match' + ) { + if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context); + } + + // other constants and contexts only pin the call below + } + // a trimmed string that is not '' was a non-empty string already if ( - $unwrappedSubject instanceof Expr\FuncCall - && in_array($unwrappedSubject->name->toLowerString(), ['trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim'], true) + in_array($call->name->toLowerString(), ['trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim'], true) ) { if ($context->false()) { $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { - $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argExpr = $call->getArgs()[0]->value; $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); if ($argResult === null) { return null; @@ -285,13 +505,12 @@ private function specifyAgainstScalarLiteral( // a known parent class narrows the argument to the child side of it if ( - $unwrappedSubject instanceof Expr\FuncCall - && $unwrappedSubject->name->toLowerString() === 'get_parent_class' + $call->name->toLowerString() === 'get_parent_class' ) { if ($context->true()) { $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argExpr = $call->getArgs()[0]->value; $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); if ($argResult === null) { return null; @@ -319,15 +538,14 @@ private function specifyAgainstScalarLiteral( // non-empty (non-falsy for a non-falsy literal) string argument; // case-mapping functions pin the case accessory on the literal side if ( - $unwrappedSubject instanceof Expr\FuncCall - && in_array($unwrappedSubject->name->toLowerString(), [ + in_array($call->name->toLowerString(), [ 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', 'ucwords', 'mb_convert_case', 'mb_convert_kana', ], true) ) { if ($context->truthy() && $constantType->isNonEmptyString()->yes()) { - $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argExpr = $call->getArgs()[0]->value; $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); if ($argResult === null) { return null; @@ -336,7 +554,7 @@ private function specifyAgainstScalarLiteral( if ($argType->isString()->yes()) { $types = new SpecifiedTypes(); - $funcName = $unwrappedSubject->name->toLowerString(); + $funcName = $call->name->toLowerString(); if (in_array($funcName, ['strtolower', 'mb_strtolower'], true)) { $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $constantExpr, $constantResult, TypeCombinator::intersect($constantType, new AccessoryLowercaseStringType()), $context); } elseif (in_array($funcName, ['strtoupper', 'mb_strtoupper'], true)) { @@ -363,14 +581,17 @@ private function specifyAgainstScalarLiteral( // count($x) === N reconstructs the array shape by its size - before // the decided guard so exhaustive size switches keep collapsing if ( - $unwrappedSubject instanceof Expr\FuncCall - && in_array($unwrappedSubject->name->toLowerString(), ['count', 'sizeof'], true) + in_array($call->name->toLowerString(), ['count', 'sizeof'], true) ) { if (!$constantType->isInteger()->yes()) { return null; } - $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argExpr = $call->getArgs()[0]->value; + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); + } + $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); if ($argResult === null) { return null; @@ -387,11 +608,11 @@ private function specifyAgainstScalarLiteral( ); } - $countTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedSubject, $argType, $constantType, $context, $evaluationScope, $unwrappedSubject); + $countTypes = $this->countNarrowingHelper->specifyCountSize($call, $argType, $constantType, $context, $evaluationScope, $call); if ($countTypes !== null) { // the old path pinned the call only through the remembered // wrapper; the composed pin covers wrapper and call alike - if ($subject !== $unwrappedSubject) { + if ($subject !== $call) { return $countTypes->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); } @@ -415,21 +636,24 @@ private function specifyAgainstScalarLiteral( // strlen($x) === 0 empties $x; === N >= 1 makes it non-empty in the // truthy direction (>= 2 non-falsy) - before the decided guard if ( - $unwrappedSubject instanceof Expr\FuncCall - && in_array($unwrappedSubject->name->toLowerString(), ['strlen', 'mb_strlen'], true) + in_array($call->name->toLowerString(), ['strlen', 'mb_strlen'], true) ) { - if (count($unwrappedSubject->getArgs()) !== 1 || !$constantType->isInteger()->yes()) { + if (count($call->getArgs()) !== 1 || !$constantType->isInteger()->yes()) { return null; } - $argExpr = $unwrappedSubject->getArgs()[0]->value; + $argExpr = $call->getArgs()[0]->value; + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); + } + if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( $this->defaultNarrowingHelper->createForSubject($argExpr, new ConstantStringType(''), $context, $evaluationScope), ); } - if ($context->truthy()) { + if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); if ($argResult === null) { return null; @@ -451,8 +675,7 @@ private function specifyAgainstScalarLiteral( // gettype($x) === 'string' narrows $x by the named type in either // direction - before the decided-comparison guard, like the old block if ( - $unwrappedSubject instanceof Expr\FuncCall - && $unwrappedSubject->name->toLowerString() === 'gettype' + $call->name->toLowerString() === 'gettype' ) { $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) !== 1) { @@ -461,20 +684,15 @@ private function specifyAgainstScalarLiteral( $gettypeNarrowedType = $this->getTypeFromGettypeStringValue($constantStrings[0]->getValue()); if ($gettypeNarrowedType !== null) { return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedSubject->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), + $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), ); } // an unknown type-name string only pins the call itself below } - $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); - if ($decidedTypes !== null) { - return $decidedTypes; - } - // get_class($o) === 'Foo' pins $o to a final Foo when the comparison // holds; outside the true context only the call itself narrows - if ($unwrappedSubject instanceof Expr\FuncCall && $context->true()) { + if (in_array($call->name->toLowerString(), ['get_class', 'get_debug_type'], true) && $context->true()) { $narrowedObjectType = null; $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { @@ -485,7 +703,7 @@ private function specifyAgainstScalarLiteral( if ($narrowedObjectType !== null) { return $this->defaultNarrowingHelper->createForSubject( - $unwrappedSubject->getArgs()[0]->value, + $call->getArgs()[0]->value, $narrowedObjectType, $context, $evaluationScope, @@ -493,191 +711,8 @@ private function specifyAgainstScalarLiteral( } } - $types = $this->defaultNarrowingHelper->createSubjectTypes( - $evaluationScope, - $subject, - $subjectResult, - $constantType, - $context, - ); - - // a single-valued subject pins its value onto the literal side too - $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - if (count($subjectType->getFiniteTypes()) === 1) { - $types = $types->unionWith($this->defaultNarrowingHelper->createSubjectTypes( - $evaluationScope, - $constantExpr, - $constantResult, - $subjectType, - $context, - )); - } - - return $types; - } - - /** - * A statically decided comparison tells the false context nothing: the - * branch is dead on the certain flavour, and subtracting the constant - * would wrongly leak into the wider native flavour (mixed~'ab'). The - * NeverType entries mirror the old identical tail's no-op. - * - * @param callable(): Type $identicalTypeCallback - */ - private function specifyDecidedComparison( - Expr $left, - Expr $right, - ExpressionResult $leftResult, - ExpressionResult $rightResult, - TypeSpecifierContext $context, - MutatingScope $evaluationScope, - callable $identicalTypeCallback, - ): ?SpecifiedTypes - { - if (!$context->false()) { - return null; - } - - $identicalType = $identicalTypeCallback(); - $isTrue = $identicalType->isTrue()->yes(); - if (!$isTrue && !$identicalType->isFalse()->yes()) { - return null; - } - - $never = new NeverType(); - $contextForTypes = $isTrue ? $context->negate() : $context; - - return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $never, $contextForTypes)->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $never, $contextForTypes), - ); - } - - private function getTypeFromGettypeStringValue(string $value): ?Type - { - if ($value === 'string') { - return new StringType(); - } - if ($value === 'array') { - return new ArrayType(new MixedType(), new MixedType()); - } - if ($value === 'boolean') { - return new BooleanType(); - } - if (in_array($value, ['resource', 'resource (closed)'], true)) { - return new ResourceType(); - } - if ($value === 'integer') { - return new IntegerType(); - } - if ($value === 'double') { - return new FloatType(); - } - if ($value === 'NULL') { - return new NullType(); - } - if ($value === 'object') { - return new ObjectWithoutClassType(); - } - - return null; - } - - /** - * The general expr-vs-expr tail of the identical narrowing: a - * single-valued side pins its value onto the other, otherwise both sides - * pin each other's types in the true context and cross-exclude in the - * false one. Runs only for operand shapes whose specialized narrowing is - * already composed - calls and ::class fetches still fall back. - * - * @param callable(): Type $identicalTypeCallback - */ - private function specifyGeneral( - NodeScopeResolver $nodeScopeResolver, - Expr $left, - Expr $right, - ExpressionResult $leftResult, - ExpressionResult $rightResult, - TypeSpecifierContext $context, - MutatingScope $evaluationScope, - callable $identicalTypeCallback, - ): ?SpecifiedTypes - { - $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; - $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; - - // calls narrow their arguments and ::class fetches their class by - // TYPE-based constants too - those old-world blocks are not composed - foreach ([$unwrappedLeft, $unwrappedRight] as $side) { - if ($side instanceof Expr\FuncCall) { - return null; - } - if ($side instanceof Expr\ClassConstFetch && $side->class instanceof Expr) { - return null; - } - } - - $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - - // a side whose TYPE is a constant bool delegates into the subject's - // own bool-context narrowing - only the literal form is composed - foreach ([$leftType, $rightType] as $sideType) { - if ($sideType->isTrue()->yes() || $sideType->isFalse()->yes()) { - return null; - } - } - - $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); - if ($decidedTypes !== null) { - return $decidedTypes; - } - - $types = null; - if ( - count($leftType->getFiniteTypes()) === 1 - || ( - $context->true() - && $leftType->isConstantValue()->yes() - && !$rightType->equals($leftType) - && $rightType->isSuperTypeOf($leftType)->yes()) - ) { - $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context); - } - if ( - count($rightType->getFiniteTypes()) === 1 - || ( - $context->true() - && $rightType->isConstantValue()->yes() - && !$leftType->equals($rightType) - && $leftType->isSuperTypeOf($rightType)->yes() - ) - ) { - $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context); - $types = $types !== null ? $types->unionWith($leftTypes) : $leftTypes; - } - - if ($types !== null) { - return $types; - } - - $leftExprString = $this->exprPrinter->printExpr($unwrappedLeft); - $rightExprString = $this->exprPrinter->printExpr($unwrappedRight); - if ($leftExprString === $rightExprString) { - if (!$unwrappedLeft instanceof Expr\Variable || !$unwrappedRight instanceof Expr\Variable) { - return new SpecifiedTypes([], []); - } - } - - if ($context->true()) { - return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context)->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context), - ); - } elseif ($context->false()) { - return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context)->normalize($evaluationScope, $nodeScopeResolver) - ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context)->normalize($evaluationScope, $nodeScopeResolver)); - } - return new SpecifiedTypes([], []); + return false; } private function isScalarLiteral(Expr $expr): bool diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 917526331c5..7fa89eb8d50 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -324,6 +324,31 @@ public function generalExprVsExpr($five, $is, Suit $suit, Suit $otherSuit, $arrO } } + /** + * @param int<2, 3> $smallSize + * @param 0 $zero + * @param 'string' $stringName + * @param list $list + * @param mixed $m + */ + public function typeBasedConstantSides(array $list, string $s, int $smallSize, int $zero, string $stringName, $m): void + { + if (count($list) === $smallSize) { + assertType('array{0: string, 1: string, 2?: string}', $list); + } + if (count($list) === $zero) { + assertType('array{}', $list); + } + if (strlen($s) === $smallSize) { + assertType('non-falsy-string', $s); + } + if (gettype($m) === $stringName) { + assertType('string', $m); + } else { + assertType('mixed~string', $m); + } + } + public function classConstFetchNarrowing(object $o): void { if ($o::class === Foo::class) { From b149cf99033bde56e23999c6dfd39f3997f1f16d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 13:21:39 +0200 Subject: [PATCH 259/378] Narrow loose-equality comparisons in IdenticalNarrowingHelper specifyEqual() composes == (and != via the negated context) from the operand results with no synthetic nodes: a single-valued side reduces to falsy-set pins or truthiness delegation, and the shapes where coercion cannot differ - gettype/get_class results, preg_match, ::class, bool-coerced sides, same-type scalars and enums - delegate directly into the identical narrowing that was previously reached through a synthesized Identical node. Uncovered shapes still fall back to the old-world Equal path. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 18 ++ .../Helper/IdenticalNarrowingHelper.php | 187 ++++++++++++++++++ .../nsrt/equality-narrowing-new-world.php | 45 +++++ 3 files changed, 250 insertions(+) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 6cd335844d7..a5ccc5b38b3 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -305,6 +305,24 @@ static function () use ($nodeScopeResolver, $expr, $scope): Type { )->setRootExpr($expr); } + if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { + // `!=` narrowing is the `==` narrowing in the negated context + if (!($context->null() && $expr instanceof BinaryOp\NotEqual)) { + $newWorldTypes = $this->identicalNarrowingHelper->specifyEqual( + $nodeScopeResolver, + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, + $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + ); + if ($newWorldTypes !== null) { + return $newWorldTypes->setRootExpr($expr); + } + } + } + if ($expr instanceof BinaryOp\Equal) { return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context, $resultFor); } diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 2f50a6d84bb..ccad53beeb3 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -24,6 +25,7 @@ use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FloatType; @@ -64,6 +66,7 @@ public function __construct( private ReflectionProvider $reflectionProvider, private CountNarrowingHelper $countNarrowingHelper, private ExprPrinter $exprPrinter, + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, ) { } @@ -442,6 +445,190 @@ private function specifyGeneral( return new SpecifiedTypes([], []); } + /** + * New-world narrowing for `==` (and, via a negated context, `!=`): + * loose comparisons reduce to falsy-set pins, truthiness delegation, or + * the identical narrowing when coercion cannot differ - all composed + * from the operand results, no synthetic nodes. Uncovered shapes return + * null and fall back to the old-world Equal path. + */ + public function specifyEqual( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ): ?SpecifiedTypes + { + if ($context->null()) { + return null; + } + + $identicalTypeCallback = fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($evaluationScope, new Expr\BinaryOp\Identical($left, $right), $nodeScopeResolver)->type; + + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + $leftScalarValues = $leftType->getConstantScalarValues(); + $rightScalarValues = $rightType->getConstantScalarValues(); + if (count($leftScalarValues) === 1 && !$unwrappedRight instanceof Expr\ConstFetch) { + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $right, $rightResult, $leftScalarValues[0], $leftType, $rightType, $context, $evaluationScope, $identicalTypeCallback); + if ($constantSideTypes !== false) { + return $constantSideTypes; + } + } elseif (count($rightScalarValues) === 1 && !$unwrappedLeft instanceof Expr\ConstFetch) { + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $left, $leftResult, $rightScalarValues[0], $rightType, $leftType, $context, $evaluationScope, $identicalTypeCallback); + if ($constantSideTypes !== false) { + return $constantSideTypes; + } + } + + // a side that coerces to a known bool compares the other side's + // truthiness - the literal-bool identical narrowing composes it + $leftBool = $leftType->toBoolean(); + if (($leftBool->isTrue()->yes() || $leftBool->isFalse()->yes()) && $rightType->isBoolean()->yes()) { + // the literal side of the delegation needs no result; the subject side is the right operand + return $this->specifyIdentical($nodeScopeResolver, new Expr\ConstFetch(new Name($leftBool->isTrue()->yes() ? 'true' : 'false')), $right, $rightResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + } + $rightBool = $rightType->toBoolean(); + if (($rightBool->isTrue()->yes() || $rightBool->isFalse()->yes()) && $leftType->isBoolean()->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, new Expr\ConstFetch(new Name($rightBool->isTrue()->yes() ? 'true' : 'false')), $leftResult, $leftResult, $context, $evaluationScope, $identicalTypeCallback); + } + + // an empty constant array equals only empty countables + if ($rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, new NonEmptyArrayType(), $context->negate()); + } + if ($leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, new NonEmptyArrayType(), $context->negate()); + } + + // same-type sides cannot coerce - loose equals strict + if ( + ($leftType->isString()->yes() && $rightType->isString()->yes()) + || ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) + || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) + || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) + ) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + } + + $leftExprString = $this->exprPrinter->printExpr($left); + $rightExprString = $this->exprPrinter->printExpr($right); + if ($leftExprString === $rightExprString) { + if (!$left instanceof Expr\Variable || !$right instanceof Expr\Variable) { + return new SpecifiedTypes([], []); + } + } + + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context); + $rightTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context); + + return $context->true() + ? $leftTypes->unionWith($rightTypes) + : $leftTypes->normalize($evaluationScope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($evaluationScope, $nodeScopeResolver)); + } + + /** + * The == narrowing against a single-valued side: a family answer, null + * to fall back to the old-world path, or false when nothing matched and + * the caller continues with the coercion branches. + * + * @param callable(): Type $identicalTypeCallback + * @return SpecifiedTypes|false|null + */ + private function specifyEqualAgainstConstantSide( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + Expr $subject, + ExpressionResult $subjectResult, + mixed $value, + Type $constantType, + Type $otherType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): SpecifiedTypes|false|null + { + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + + if ($value === null) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantArrayType([], []), + ]), $context); + } + + // a bool constant compares by the subject's truthiness + if ($value === false) { + return $subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), + ); + } + if ($value === true) { + return $subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), + ); + } + + /* There is a difference between php 7.x and 8.x on the equality + * behavior between zero and the empty string, so to be conservative + * we leave it untouched regardless of the language version */ + if ($value === 0 && !$otherType->isInteger()->yes() && !$otherType->isBoolean()->yes()) { + $trueTypes = $context->true() + ? [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new StringType()] + : [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new ConstantStringType('0')]; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType($trueTypes), $context); + } + if ($value === '') { + $trueTypes = $context->true() + ? [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new ConstantStringType('')] + : [new NullType(), new ConstantBooleanType(false), new ConstantStringType('')]; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType($trueTypes), $context); + } + + // loose equals strict for these call results and class names + if ( + $unwrappedSubject instanceof Expr\FuncCall + && $unwrappedSubject->name instanceof Name + && !$unwrappedSubject->isFirstClassCallable() + && isset($unwrappedSubject->getArgs()[0]) + ) { + $funcName = $unwrappedSubject->name->toLowerString(); + if (in_array($funcName, ['gettype', 'get_class', 'get_debug_type'], true) && $constantType->isString()->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + } + if ($context->true() && $funcName === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + } + } + if ( + $unwrappedSubject instanceof Expr\ClassConstFetch + && !($unwrappedSubject->name instanceof Expr) + && $unwrappedSubject->name->toLowerString() === 'class' + && $constantType->isString()->yes() + ) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + } + + return false; + } + /** * The function-family compositions, shared by the literal and the * TYPE-based constant sides: a family answer, null to fall back to the diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 7fa89eb8d50..29914710ad1 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -389,6 +389,51 @@ public function looseEquality($m, ?string $s): void } } + /** + * @param int|string $is + * @param array $arr + * @param 'a'|'b' $ab + */ + public function moreLooseEquality(?bool $nb, $is, string $s, array $arr, string $ab, Suit $suit, Suit $otherSuit): void + { + if ($nb == true) { + assertType('true', $nb); + } else { + assertType('false|null', $nb); + } + if ($is == 0) { + assertType('0|string', $is); + } else { + assertType('int|int<1, max>|string', $is); + } + if ($is == '') { + assertType("0|''", $is); + } else { + assertType('int|non-empty-string', $is); + } + if ($s == 'foo') { + assertType("'foo'", $s); + } + if ($ab == 'a') { + assertType("'a'", $ab); + } else { + assertType("'b'", $ab); + } + if ($arr == []) { + assertType('array{}', $arr); + } else { + assertType('non-empty-array', $arr); + } + if (gettype($is) == 'string') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if ($suit == $otherSuit) { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } + } + /** * @param int|string $a */ From 3041c05cf9740bc2f5ea3593fafc90060da5d17d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 13:29:05 +0200 Subject: [PATCH 260/378] Compose assign-time conditional holders from the assigned expression's result The falsy-sentinel loop asked the narrowing of synthesized NotIdentical/Identical comparisons against fabricated literals - up to fourteen on-demand walks per assignment. specifyIdenticalAgainstType() answers the same questions from the stored result and the sentinel type; the fabricated literal is only printed into reverse entries, never walked. The truthy/falsey holder pair reads the stored result directly. A missing stored result keeps the synthetic path. --- src/Analyser/ExprHandler/AssignHandler.php | 32 +++++-- .../Helper/IdenticalNarrowingHelper.php | 92 +++++++++++++++++-- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 5651cad0085..ef234db1f54 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -30,6 +30,7 @@ use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -57,6 +58,7 @@ use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -97,6 +99,7 @@ public function __construct( private MatchHandler $matchHandler, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, private PropertyReflectionFinder $propertyReflectionFinder, ) { @@ -506,6 +509,8 @@ public function processAssignVar( ); } + $storedAssignedExprResult = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($assignedExpr); + $truthyType = TypeCombinator::removeFalsey($type); // Value comparison, not identity: remove() happens to hand back the very same // instance when it removes nothing, but that is not part of its contract — the @@ -513,12 +518,16 @@ public function processAssignVar( // a fast path (equals() has no such shortcut, and no-op removal is the common // case here). if ($truthyType !== $type && !$truthyType->equals($type)) { - $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $truthySpecifiedTypes = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy()) + : $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); - $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + $falseySpecifiedTypes = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey()) + : $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } @@ -547,13 +556,24 @@ public function processAssignVar( $astNode = new Node\Expr\Array_($falseyScalar); } - $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notIdenticalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); + // the identical verdict of "assigned expr vs the sentinel": + // the loop guarantees the sentinel is a possible value, so + // only always-the-sentinel is decided + $identicalTypeCallback = static fn (): Type => $type->equals($falseyType) + ? new ConstantBooleanType(true) + : new BooleanType(); + + $notIdenticalSpecifiedTypes = $storedAssignedExprResult !== null + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createFalse(), $scope, $identicalTypeCallback) + : null; + $notIdenticalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $identicalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); + $identicalSpecifiedTypes = $storedAssignedExprResult !== null + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createTrue(), $scope, $identicalTypeCallback) + : null; + $identicalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index ccad53beeb3..b81eabe6e19 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -190,7 +190,7 @@ private function specifyAgainstScalarLiteral( $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if ($unwrappedSubject instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantResult, $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); if ($familyTypes === null) { return null; } @@ -380,9 +380,9 @@ private function specifyGeneral( // normalization that moved the call to the left if ($unwrappedLeft instanceof Expr\FuncCall || $unwrappedRight instanceof Expr\FuncCall) { if ($unwrappedLeft instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightResult, $rightType, $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope); } else { - $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftResult, $leftType, $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope); } if ($familyTypes === null) { return null; @@ -533,6 +533,87 @@ public function specifyEqual( : $leftTypes->normalize($evaluationScope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($evaluationScope, $nodeScopeResolver)); } + /** + * Identity narrowing of a subject against a known constant type - the + * entry point for callers that hold no comparison node at all (the + * assign-time conditional holders compare the assigned expression with + * falsy sentinels). $constantExpr is only printed into reverse entries, + * never walked. Null means the shape is not composed and the caller + * keeps its old-world path. + * + * @param callable(): Type $identicalTypeCallback + */ + public function specifyIdenticalAgainstType( + Expr $subject, + ExpressionResult $subjectResult, + Expr $constantExpr, + Type $constantType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if ($context->null()) { + return null; + } + + if ($constantType->isNull()->yes()) { + // unguarded like the null-literal slice - the subtraction entry + // must keep firing conditional holders + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new NullType(), $context); + } + + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + + if ($constantType->isTrue()->yes() || $constantType->isFalse()->yes()) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new ConstantBooleanType($constantType->isTrue()->yes()), $context); + if (!$context->true() && ($unwrappedSubject instanceof Expr\NullsafeMethodCall || $unwrappedSubject instanceof Expr\NullsafePropertyFetch)) { + return $types; + } + + $boolContext = $constantType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + + return $types->unionWith($subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? $boolContext : $boolContext->negate(), + )); + } + + if ($unwrappedSubject instanceof Expr\FuncCall) { + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantType, $context, $evaluationScope); + if ($familyTypes === null) { + return null; + } + if ($familyTypes !== false) { + return $familyTypes; + } + } elseif ($unwrappedSubject instanceof Expr\ClassConstFetch && $unwrappedSubject->class instanceof Expr) { + return null; + } + + if ($context->false()) { + $identicalType = $identicalTypeCallback(); + $isTrue = $identicalType->isTrue()->yes(); + if ($isTrue || $identicalType->isFalse()->yes()) { + $never = new NeverType(); + $contextForTypes = $isTrue ? $context->negate() : $context; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $never, $contextForTypes)->unionWith( + $this->defaultNarrowingHelper->createForSubject($constantExpr, $never, $contextForTypes, $evaluationScope), + ); + } + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context); + + $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($subjectType->getFiniteTypes()) === 1) { + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($constantExpr, $subjectType, $context, $evaluationScope)); + } + + return $types; + } + /** * The == narrowing against a single-valued side: a family answer, null * to fall back to the old-world path, or false when nothing matched and @@ -642,7 +723,6 @@ private function specifyFuncCallFamilies( ExpressionResult $subjectResult, Expr\FuncCall $call, Expr $constantExpr, - ExpressionResult $constantResult, Type $constantType, TypeSpecifierContext $context, MutatingScope $evaluationScope, @@ -743,9 +823,9 @@ private function specifyFuncCallFamilies( $types = new SpecifiedTypes(); $funcName = $call->name->toLowerString(); if (in_array($funcName, ['strtolower', 'mb_strtolower'], true)) { - $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $constantExpr, $constantResult, TypeCombinator::intersect($constantType, new AccessoryLowercaseStringType()), $context); + $types = $this->defaultNarrowingHelper->createForSubject($constantExpr, TypeCombinator::intersect($constantType, new AccessoryLowercaseStringType()), $context, $evaluationScope); } elseif (in_array($funcName, ['strtoupper', 'mb_strtoupper'], true)) { - $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $constantExpr, $constantResult, TypeCombinator::intersect($constantType, new AccessoryUppercaseStringType()), $context); + $types = $this->defaultNarrowingHelper->createForSubject($constantExpr, TypeCombinator::intersect($constantType, new AccessoryUppercaseStringType()), $context, $evaluationScope); } $accessory = $constantType->isNonFalsyString()->yes() From cdb14dc19825209949a40e4f7226382099093ee6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 13:34:29 +0200 Subject: [PATCH 261/378] Read assign-time ternary holder types from the captured walk results The RHS ternary's cond/if/else were already evaluated on the cond-filtered scopes during the walk. A WeakMap capture on TernaryHandler replaces the duplicate-storage cond re-walk and the filtered-scope type reads; short ternaries and unwalked nodes keep the old path. --- src/Analyser/ExprHandler/AssignHandler.php | 33 ++++++++++++++------- src/Analyser/ExprHandler/TernaryHandler.php | 20 +++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index ef234db1f54..24d4ca554be 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -97,6 +97,7 @@ public function __construct( private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private TernaryHandler $ternaryHandler, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, private IdenticalNarrowingHelper $identicalNarrowingHelper, @@ -479,17 +480,29 @@ public function processAssignVar( $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { - $if = $assignedExpr->if; - if ($if === null) { - $if = $assignedExpr->cond; + // the walk already evaluated the arms on the cond-filtered + // scopes - read the captured results instead of re-walking + $capturedTernary = $this->ternaryHandler->getCapturedResults($assignedExpr); + if ($capturedTernary !== null) { + [$ternaryCondResult, $ternaryIfResult, $ternaryElseResult] = $capturedTernary; + $condScope = $ternaryCondResult->getScope(); + $truthySpecifiedTypes = $ternaryCondResult->getSpecifiedTypesForScope($condScope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $ternaryCondResult->getSpecifiedTypesForScope($condScope, TypeSpecifierContext::createFalsey()); + $truthyType = $ternaryIfResult->getType(); + $falseyType = $ternaryElseResult->getType(); + } else { + $if = $assignedExpr->if; + if ($if === null) { + $if = $assignedExpr->cond; + } + $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); + $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); + $truthyType = $nodeScopeResolver->readTypeOfMaybeStored($if, $truthyScope); + $falseyType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr->else, $falsyScope); } - $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); - $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); - $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $nodeScopeResolver->readTypeOfMaybeStored($if, $truthyScope); - $falseyType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr->else, $falsyScope); if ( $truthyType->isSuperTypeOf($falseyType)->no() diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 349bf45f3d8..c214036ee03 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -20,6 +20,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NeverType; use PHPStan\Type\Type; +use WeakMap; use PHPStan\Type\TypeCombinator; use function array_merge; @@ -30,11 +31,15 @@ final class TernaryHandler implements ExprHandler { + /** @var WeakMap */ + private WeakMap $capturedResults; + public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { + $this->capturedResults = new WeakMap(); } public function supports(Expr $expr): bool @@ -42,6 +47,17 @@ public function supports(Expr $expr): bool return $expr instanceof Ternary; } + /** + * The cond/if/else results captured during the walk, for the assign-time + * conditional holders - null for short ternaries and unwalked nodes. + * + * @return array{ExpressionResult, ExpressionResult, ExpressionResult}|null + */ + public function getCapturedResults(Ternary $expr): ?array + { + return $this->capturedResults[$expr] ?? null; + } + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $ternaryCondResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -76,6 +92,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } + if ($ifResult !== null) { + $this->capturedResults[$expr] = [$ternaryCondResult, $ifResult, $elseResult]; + } + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; From 8d7d1a7113b55795ec9fa309bd80482b4fdfb99b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 14:35:19 +0200 Subject: [PATCH 262/378] Extract conjunction narrowing into BooleanNarrowingHelper BooleanAnd's specify semantics, taking per-operand narrowing closures and branch scopes instead of the operands' ExpressionResults - so conjunctions without an AST node, like the falsy fold of a multi-subject isset(), can reuse it without synthesizing BooleanAnd chains. A pure move; BooleanAndHandler delegates. --- .../ExprHandler/BooleanAndHandler.php | 108 ++------------ .../Helper/BooleanNarrowingHelper.php | 140 ++++++++++++++++++ 2 files changed, 155 insertions(+), 93 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 682dca47ec9..fd0ee963feb 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -11,7 +11,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -23,7 +23,6 @@ use PHPStan\Type\NeverType; use PHPStan\Type\Type; use function array_merge; -use function is_string; /** * @implements ExprHandler @@ -33,7 +32,7 @@ final class BooleanAndHandler implements ExprHandler { public function __construct( - private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, ) { @@ -44,33 +43,6 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanAnd || $expr instanceof LogicalAnd; } - private function allExpressionsTrackable(SpecifiedTypes $types): bool - { - foreach ($types->getSureTypes() as [$expr]) { - if (!$this->isTrackableExpression($expr)) { - return false; - } - } - foreach ($types->getSureNotTypes() as [$expr]) { - if (!$this->isTrackableExpression($expr)) { - return false; - } - } - - return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== []; - } - - private function isTrackableExpression(Expr $expr): bool - { - if ($expr instanceof Expr\Variable) { - return is_string($expr->name); - } - - return $expr instanceof Expr\PropertyFetch - || $expr instanceof Expr\ArrayDimFetch - || $expr instanceof Expr\StaticPropertyFetch; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -124,69 +96,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { - $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); - $rightScope = $leftResult->getTruthyScope(); - $rightTypes = $rightResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); - if ($context->true()) { - $types = $leftTypes->unionWith($rightTypes); - } else { - $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); - $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); - $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getFalseyScope(), $rightResult->getFalseyScope(), $types); - } - if ($context->false()) { - // Consequent (holder) narrowings projected by each holder: these must be - // the genuine falsey narrowing of the arm. When that is empty, the arm - // has no sound falsey narrowing and must not contribute a consequent. - $leftHolderTypes = $leftTypes; - $rightHolderTypes = $rightTypes; - // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. - if ($context->truthy()) { - if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { - $leftHolderTypes = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { - $rightHolderTypes = $rightResult->getSpecifiedTypesForScope($rightScope, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - } - // Condition (antecedent) narrowings: when an arm has no falsey narrowing - // (e.g. isset() on an array dim fetch), derive the condition from the truthy - // narrowing by swapping sure/sureNot types. This swap is only sound for the - // antecedent — processBooleanConditionalTypes inverts it back to the truthy - // narrowing. It must NOT feed the consequent: inverting a comparison's truthy - // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would - // over-narrow the consequent (see regression for `$x === $nonConstantString`). - $leftCondTypes = $leftHolderTypes; - $rightCondTypes = $rightHolderTypes; - if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === []) { - $truthyLeftTypes = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); - } - } - if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { - $truthyRightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); - } - } - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, false, true, $s, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, true, true, $s, $expr->left), - ]))->setRootExpr($expr); - } - - return $types; + return $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $expr->left, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), + $leftResult->getTruthyScope(), + $leftResult->getFalseyScope(), + $expr->right, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), + $rightResult->getFalseyScope(), + ); }, ); } diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php new file mode 100644 index 00000000000..ae646bebedf --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -0,0 +1,140 @@ +setRootExpr($rootExpr); + $rightScope = $leftTruthyScope; + $rightTypes = $rightTypesCallback($s, $context)->setRootExpr($rootExpr); + if ($context->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftFalseyScope, $rightFalseyScope, $types); + } + if ($context->false()) { + // Consequent (holder) narrowings projected by each holder: these must be + // the genuine falsey narrowing of the arm. When that is empty, the arm + // has no sound falsey narrowing and must not contribute a consequent. + $leftHolderTypes = $leftTypes; + $rightHolderTypes = $rightTypes; + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. + if ($context->truthy()) { + if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { + $leftHolderTypes = $leftTypesCallback($s, TypeSpecifierContext::createFalsey())->setRootExpr($rootExpr); + } + if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { + $rightHolderTypes = $rightTypesCallback($rightScope, TypeSpecifierContext::createFalsey())->setRootExpr($rootExpr); + } + } + // Condition (antecedent) narrowings: when an arm has no falsey narrowing + // (e.g. isset() on an array dim fetch), derive the condition from the truthy + // narrowing by swapping sure/sureNot types. This swap is only sound for the + // antecedent — processBooleanConditionalTypes inverts it back to the truthy + // narrowing. It must NOT feed the consequent: inverting a comparison's truthy + // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would + // over-narrow the consequent (see regression for `$x === $nonConstantString`). + $leftCondTypes = $leftHolderTypes; + $rightCondTypes = $rightHolderTypes; + if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === []) { + $truthyLeftTypes = $leftTypesCallback($s, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { + $truthyRightTypes = $rightTypesCallback($rightScope, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, false, true, $s, $leftExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, true, true, $s, $leftExpr), + ]))->setRootExpr($rootExpr); + } + + return $types; + } + + private function allExpressionsTrackable(SpecifiedTypes $types): bool + { + foreach ($types->getSureTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + foreach ($types->getSureNotTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + + return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== []; + } + + private function isTrackableExpression(Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return is_string($expr->name); + } + + return $expr instanceof Expr\PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch; + } + + +} From e1ce90e4c31dc4b5b64d6011c590f7b0859f194d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 14:41:50 +0200 Subject: [PATCH 263/378] Fold multi-subject isset narrowing through the conjunction helper The non-true narrowing of isset($a, $b) synthesized one Isset_ node per subject plus a BooleanAnd chain and re-walked it per ask, discarding the captured chain results. The fold runs each subject's composed narrowing through BooleanNarrowingHelper::specifyConjunction on progressively truthy-filtered scopes; the fabricated nodes survive only as holder print keys. The single-subject non-true block moves into specifySingleSubjectNonTrue, shared by both paths. --- src/Analyser/ExprHandler/IssetHandler.php | 278 +++++++++++++--------- 1 file changed, 165 insertions(+), 113 deletions(-) diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 7dee17c5a9e..c701599d75c 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use ArrayAccess; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; @@ -18,6 +19,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; @@ -63,6 +65,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -190,140 +193,189 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $types->setRootExpr($expr); } - // non-true contexts (only SOME subject is unset) keep the - // and-chained single-param rewrite - $issets = []; - foreach ($expr->vars as $var) { - $issets[] = new Isset_([$var], $expr->getAttributes()); - } - - $first = array_shift($issets); - $andChain = null; - foreach ($issets as $isset) { - if ($andChain === null) { - $andChain = new BooleanAnd($first, $isset); - continue; - } - - $andChain = new BooleanAnd($andChain, $isset); - } + // non-true contexts (only SOME subject is unset): fold the + // subjects through the conjunction narrowing; the fabricated + // Isset_/BooleanAnd nodes are only printed into holder keys, + // never walked + $makeSubjectTypes = function (Expr $var, ExpressionResult $varResult) use ($nodeScopeResolver, $chainResults, $expr): Closure { + return function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $chainResults, $expr, $var, $varResult): SpecifiedTypes { + $scopedReadType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope, $nodeScopeResolver); + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes(new Isset_([$var], $expr->getAttributes()), $ctx); + } + if (!$ctx->true()) { + return $this->specifySingleSubjectNonTrue($scope, $var, $varResult, $scopedReadType, $ctx, $expr); + } - if ($andChain === null) { - throw new ShouldNotHappenException(); + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $var, $scopedReadType, $expr, $ctx); + }; + }; + + $accExpr = new Isset_([$expr->vars[0]], $expr->getAttributes()); + $accTypes = $makeSubjectTypes($expr->vars[0], $varResults[0]); + $accTruthyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createFalsey())); + + for ($i = 1, $varCount = count($expr->vars); $i < $varCount; $i++) { + $rightExprNode = new Isset_([$expr->vars[$i]], $expr->getAttributes()); + $rightTypes = $makeSubjectTypes($expr->vars[$i], $varResults[$i]); + $rightFalseyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createFalsey())); + + $leftExprNode = $accExpr; + $leftTypes = $accTypes; + $leftTruthyScope = $accTruthyScope; + $leftFalseyScope = $accFalseyScope; + $accTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $expr, + $leftExprNode, + $leftTypes, + $leftTruthyScope, + $leftFalseyScope, + $rightExprNode, + $rightTypes, + $rightFalseyScope, + ); + $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); + $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createFalsey())); } - return $this->defaultNarrowingHelper->specifyTypesForNode($s, $andChain, $context)->setRootExpr($expr); + return $accTypes($s, $context)->setRootExpr($expr); } $issetExpr = $expr->vars[0]; if (!$context->true()) { - $isset = $varResults[0]->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + return $this->specifySingleSubjectNonTrue($s, $issetExpr, $varResults[0], $readType, $context, $expr); + } - if ($isset === false) { - return new SpecifiedTypes(); - } + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $issetExpr, $readType, $expr, $context); + }, + ); + } - $type = $readType($issetExpr); - $isNullable = !$type->isNull()->no(); - $exprType = $this->defaultNarrowingHelper->createForSubject( - $issetExpr, - new NullType(), - $context->negate(), - $s, - )->setRootExpr($expr); - - if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { - if ($isset === true) { - if ($isNullable) { - return $exprType; - } - // variable cannot exist in !isset() - return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context, - $s, - ))->setRootExpr($expr); - } + /** + * The non-true narrowing of a single isset() subject, composed from its + * captured result - shared by the single-subject path and the multi- + * subject conjunction fold. + * + * @param callable(Expr): Type $readType + */ + private function specifySingleSubjectNonTrue( + MutatingScope $s, + Expr $issetExpr, + ExpressionResult $varResult, + callable $readType, + TypeSpecifierContext $context, + Expr $rootExpr, + ): SpecifiedTypes + { + $isset = $varResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + + if ($isset === false) { + return new SpecifiedTypes(); + } + + $type = $readType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->defaultNarrowingHelper->createForSubject( + $issetExpr, + new NullType(), + $context->negate(), + $s, + )->setRootExpr($rootExpr); + + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; + } + + // variable cannot exist in !isset() + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + ))->setRootExpr($rootExpr); + } + + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $s, + ))->setRootExpr($rootExpr); + } + + // variable cannot exist in !isset() + return $this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + )->setRootExpr($rootExpr); + } - if ($isNullable) { - // reduces variable certainty to maybe - return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context->negate(), - $s, - ))->setRootExpr($expr); + if ($isNullable && $isset === true) { + return $exprType; + } + + if ( + $issetExpr instanceof ArrayDimFetch + && $issetExpr->dim !== null + ) { + $varType = $readType($issetExpr->var); + if (!$varType instanceof MixedType) { + $dimType = $readType($issetExpr->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $constantArrays = $varType->getConstantArrays(); + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + $hasOffset = $constantArray->hasOffsetValueType($dimType); + if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + continue; } - // variable cannot exist in !isset() - return $this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context, - $s, - )->setRootExpr($expr); + $typesToRemove[] = $constantArray; } - if ($isNullable && $isset === true) { - return $exprType; - } + if ($typesToRemove !== []) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); - if ( - $issetExpr instanceof ArrayDimFetch - && $issetExpr->dim !== null - ) { - $varType = $readType($issetExpr->var); - if (!$varType instanceof MixedType) { - $dimType = $readType($issetExpr->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $constantArrays = $varType->getConstantArrays(); - $typesToRemove = []; - foreach ($constantArrays as $constantArray) { - $hasOffset = $constantArray->hasOffsetValueType($dimType); - if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { - continue; - } - - $typesToRemove[] = $constantArray; - } - - if ($typesToRemove !== []) { - $typeToRemove = TypeCombinator::union(...$typesToRemove); - - $result = $this->defaultNarrowingHelper->createForSubject( - $issetExpr->var, - $typeToRemove, - TypeSpecifierContext::createFalse(), - $s, - )->setRootExpr($expr); - - if ($s->hasExpressionType($issetExpr->var)->maybe()) { - $result = $result->unionWith( - $this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr->var), - new NullType(), - TypeSpecifierContext::createTruthy(), - $s, - )->setRootExpr($expr), - ); - } - - return $result; - } - } + $result = $this->defaultNarrowingHelper->createForSubject( + $issetExpr->var, + $typeToRemove, + TypeSpecifierContext::createFalse(), + $s, + )->setRootExpr($rootExpr); + + if ($s->hasExpressionType($issetExpr->var)->maybe()) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr->var), + new NullType(), + TypeSpecifierContext::createTruthy(), + $s, + )->setRootExpr($rootExpr), + ); } - } - return new SpecifiedTypes(); + return $result; + } } + } + } - return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $issetExpr, $readType, $expr, $context); - }, - ); + return new SpecifiedTypes(); } + } From 561755ea720ec252c888d657f539da55da0d1bca Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 16:02:01 +0200 Subject: [PATCH 264/378] Extract disjunction narrowing into BooleanNarrowingHelper BooleanOr's specify semantics, taking per-operand narrowing and type closures and branch scopes instead of the operands' ExpressionResults - so disjunctions without an AST node, like the non-null narrowing of empty(), can reuse it without synthesizing BooleanOr chains. The truthy holder augmentation moves along; BooleanOrHandler delegates. --- src/Analyser/ExprHandler/BooleanOrHandler.php | 132 +++------------- .../Helper/BooleanNarrowingHelper.php | 144 ++++++++++++++++++ 2 files changed, 161 insertions(+), 115 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 5b554ae7be7..209efa3f6f4 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -11,8 +11,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -36,9 +35,8 @@ final class BooleanOrHandler implements ExprHandler { public function __construct( - private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -66,68 +64,6 @@ public function supports(Expr $expr): bool * skipped: in the OR-truthy scope the arm that didn't narrow could still be * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function augmentBooleanOrTruthyWithConditionalHolders( - NodeScopeResolver $nodeScopeResolver, - MutatingScope $scope, - MutatingScope $leftTruthyScope, - MutatingScope $rightScope, - MutatingScope $rightTruthyScope, - BooleanOr|LogicalOr $expr, - SpecifiedTypes $types, - ): SpecifiedTypes - { - $seen = []; - foreach ([$scope, $rightScope] as $sourceScope) { - foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) { - if (isset($seen[$exprString])) { - continue; - } - if ($holders === []) { - continue; - } - $seen[$exprString] = true; - $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); - - // Only project when the target stays Yes-defined in the original - // scope and in both filtered branches. A sure type implicitly - // raises certainty to Yes, which would wrongly upgrade Maybe-defined - // variables — `if (empty($a['bar']))` for instance leaves `$a` - // Maybe-defined because `empty()` tolerates undefined offsets. - if (!$scope->hasExpressionType($targetExpr)->yes()) { - continue; - } - if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - - $origType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); - $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); - $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); - - $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); - $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); - - if (!$leftNarrowed || !$rightNarrowed) { - continue; - } - - $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($origType)) { - continue; - } - - $types = $types->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()), - ); - } - } - - return $types; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -181,55 +117,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { - $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); - $rightScope = $leftResult->getFalseyScope(); - $rightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, $context)->setRootExpr($expr); - - if ($context->true()) { - if ( - $leftResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() - ) { - $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); - } elseif ( - $leftResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isTrue()->yes() - || $rightResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() - ) { - $types = $leftTypes->normalize($s, $nodeScopeResolver); - } else { - $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); - $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); - $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->augmentBooleanOrTruthyWithConditionalHolders( - $nodeScopeResolver, - $s, - $leftResult->getTruthyScope(), - $rightScope, - $rightResult->getTruthyScope(), - $expr, - $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getTruthyScope(), $rightResult->getTruthyScope(), $types); - } - } else { - $types = $leftTypes->unionWith($rightTypes); - } - - if ($context->true()) { - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, false, false, $s, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, true, false, $s, $expr->left), - ]))->setRootExpr($expr); - } - - return $types; + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $expr->left, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (MutatingScope $scope): Type => $leftResult->getTypeOnScope($scope, $scope->nativeTypesPromoted), + $leftResult->getTruthyScope(), + $leftResult->getFalseyScope(), + $expr->right, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (MutatingScope $scope): Type => $rightResult->getTypeOnScope($scope, $scope->nativeTypesPromoted), + $rightResult->getTruthyScope(), + ); }, ); } diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index ae646bebedf..d8290e22e82 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -7,6 +7,8 @@ use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; +use PHPStan\Type\TypeCombinator; +use PHPStan\Type\Type; use PHPStan\DependencyInjection\AutowiredService; use function is_string; @@ -22,6 +24,7 @@ final class BooleanNarrowingHelper public function __construct( private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -109,6 +112,147 @@ public function specifyConjunction( return $types; } + /** + * The disjunction narrowing - BooleanOr's specify semantics - composed + * from per-operand narrowing/type closures and branch scopes instead of + * the operands' ExpressionResults, so disjunctions without an AST node + * (the non-null narrowing of empty()) reuse it without synthesizing + * BooleanOr chains. + * + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $leftTypesCallback + * @param callable(MutatingScope): Type $leftTypeCallback + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $rightTypesCallback + * @param callable(MutatingScope): Type $rightTypeCallback + */ + public function specifyDisjunction( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $s, + TypeSpecifierContext $context, + Expr $rootExpr, + Expr $leftExpr, + callable $leftTypesCallback, + callable $leftTypeCallback, + MutatingScope $leftTruthyScope, + MutatingScope $leftFalseyScope, + Expr $rightExpr, + callable $rightTypesCallback, + callable $rightTypeCallback, + MutatingScope $rightTruthyScope, + ): SpecifiedTypes + { + $leftTypes = $leftTypesCallback($s, $context)->setRootExpr($rootExpr); + $rightScope = $leftFalseyScope; + $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); + + if ($context->true()) { + if ( + $leftTypeCallback($s)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); + } elseif ( + $leftTypeCallback($s)->toBoolean()->isTrue()->yes() + || $rightTypeCallback($s)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes->normalize($s, $nodeScopeResolver); + } else { + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->augmentDisjunctionTruthyWithConditionalHolders( + $nodeScopeResolver, + $s, + $leftTruthyScope, + $rightScope, + $rightTruthyScope, + $rootExpr, + $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftTruthyScope, $rightTruthyScope, $types); + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($context->true()) { + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, false, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, false, false, $s, $leftExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, true, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, true, false, $s, $leftExpr), + ]))->setRootExpr($rootExpr); + } + + return $types; + } + + private function augmentDisjunctionTruthyWithConditionalHolders( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $scope, + MutatingScope $leftTruthyScope, + MutatingScope $rightScope, + MutatingScope $rightTruthyScope, + Expr $rootExpr, + SpecifiedTypes $types, + ): SpecifiedTypes + { + $seen = []; + foreach ([$scope, $rightScope] as $sourceScope) { + foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) { + if (isset($seen[$rootExprString])) { + continue; + } + if ($holders === []) { + continue; + } + $seen[$rootExprString] = true; + $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); + + // Only project when the target stays Yes-defined in the original + // scope and in both filtered branches. A sure type implicitly + // raises certainty to Yes, which would wrongly upgrade Maybe-defined + // variables — `if (empty($a['bar']))` for instance leaves `$a` + // Maybe-defined because `empty()` tolerates undefined offsets. + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $origType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); + $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); + + $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); + $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); + + if (!$leftNarrowed || !$rightNarrowed) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + if ($unionType->equals($origType)) { + continue; + } + + $types = $types->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()), + ); + } + } + + return $types; + } + private function allExpressionsTrackable(SpecifiedTypes $types): bool { foreach ($types->getSureTypes() as [$expr]) { From e4d106e449a045fa6df1b8ae1e66d17e9a1b8486 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 17:08:40 +0200 Subject: [PATCH 265/378] Share the single-subject isset narrowing in DefaultNarrowingHelper The non-true narrowing of one isset() subject moves next to the chain machinery as createIssetSingleSubjectNonTrueTypes(), so the empty() composition can reuse it. The baseline entry for its instanceof check moves with the code. --- phpstan-baseline.neon | 8 +- .../Helper/DefaultNarrowingHelper.php | 121 +++++++++++++++++ src/Analyser/ExprHandler/IssetHandler.php | 124 +----------------- 3 files changed, 124 insertions(+), 129 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 01737d71a1d..b88fe29503d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -51,15 +51,9 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 1 + count: 2 path: src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' - identifier: phpstanApi.instanceofType - count: 1 - path: src/Analyser/ExprHandler/IssetHandler.php - - rawMessage: 'Only numeric types are allowed in pre-increment, float|int|string|null given.' identifier: preInc.nonNumeric diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 89a299fe788..f49e6ef1bee 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; +use PHPStan\Node\IssetExpr; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; @@ -359,4 +360,124 @@ public function createIssetTruthyChainTypes(MutatingScope $s, Expr $issetExpr, C return $types; } + /** + * The non-true narrowing of a single isset() subject, composed from its + * captured result - shared by IssetHandler's paths and empty()'s disjunction. + * + * @param callable(Expr): Type $readType + */ + public function createIssetSingleSubjectNonTrueTypes( + MutatingScope $s, + Expr $issetExpr, + ExpressionResult $varResult, + callable $readType, + TypeSpecifierContext $context, + Expr $rootExpr, + ): SpecifiedTypes + { + $isset = $varResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + + if ($isset === false) { + return new SpecifiedTypes(); + } + + $type = $readType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->createForSubject( + $issetExpr, + new NullType(), + $context->negate(), + $s, + )->setRootExpr($rootExpr); + + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; + } + + // variable cannot exist in !isset() + return $exprType->unionWith($this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + ))->setRootExpr($rootExpr); + } + + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $s, + ))->setRootExpr($rootExpr); + } + + // variable cannot exist in !isset() + return $this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + )->setRootExpr($rootExpr); + } + + if ($isNullable && $isset === true) { + return $exprType; + } + + if ( + $issetExpr instanceof ArrayDimFetch + && $issetExpr->dim !== null + ) { + $varType = $readType($issetExpr->var); + if (!$varType instanceof MixedType) { + $dimType = $readType($issetExpr->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $constantArrays = $varType->getConstantArrays(); + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + $hasOffset = $constantArray->hasOffsetValueType($dimType); + if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + continue; + } + + $typesToRemove[] = $constantArray; + } + + if ($typesToRemove !== []) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); + + $result = $this->createForSubject( + $issetExpr->var, + $typeToRemove, + TypeSpecifierContext::createFalse(), + $s, + )->setRootExpr($rootExpr); + + if ($s->hasExpressionType($issetExpr->var)->maybe()) { + $result = $result->unionWith( + $this->createForSubject( + new IssetExpr($issetExpr->var), + new NullType(), + TypeSpecifierContext::createTruthy(), + $s, + )->setRootExpr($rootExpr), + ); + } + + return $result; + } + } + } + } + + return new SpecifiedTypes(); + } + + + } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index c701599d75c..f844f161fe5 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -204,7 +204,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->specifyDefaultTypes(new Isset_([$var], $expr->getAttributes()), $ctx); } if (!$ctx->true()) { - return $this->specifySingleSubjectNonTrue($scope, $var, $varResult, $scopedReadType, $ctx, $expr); + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($scope, $var, $varResult, $scopedReadType, $ctx, $expr); } return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $var, $scopedReadType, $expr, $ctx); @@ -249,7 +249,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $issetExpr = $expr->vars[0]; if (!$context->true()) { - return $this->specifySingleSubjectNonTrue($s, $issetExpr, $varResults[0], $readType, $context, $expr); + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($s, $issetExpr, $varResults[0], $readType, $context, $expr); } return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $issetExpr, $readType, $expr, $context); @@ -258,124 +258,4 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } - /** - * The non-true narrowing of a single isset() subject, composed from its - * captured result - shared by the single-subject path and the multi- - * subject conjunction fold. - * - * @param callable(Expr): Type $readType - */ - private function specifySingleSubjectNonTrue( - MutatingScope $s, - Expr $issetExpr, - ExpressionResult $varResult, - callable $readType, - TypeSpecifierContext $context, - Expr $rootExpr, - ): SpecifiedTypes - { - $isset = $varResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); - - if ($isset === false) { - return new SpecifiedTypes(); - } - - $type = $readType($issetExpr); - $isNullable = !$type->isNull()->no(); - $exprType = $this->defaultNarrowingHelper->createForSubject( - $issetExpr, - new NullType(), - $context->negate(), - $s, - )->setRootExpr($rootExpr); - - if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { - if ($isset === true) { - if ($isNullable) { - return $exprType; - } - - // variable cannot exist in !isset() - return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context, - $s, - ))->setRootExpr($rootExpr); - } - - if ($isNullable) { - // reduces variable certainty to maybe - return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context->negate(), - $s, - ))->setRootExpr($rootExpr); - } - - // variable cannot exist in !isset() - return $this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr), - new NullType(), - $context, - $s, - )->setRootExpr($rootExpr); - } - - if ($isNullable && $isset === true) { - return $exprType; - } - - if ( - $issetExpr instanceof ArrayDimFetch - && $issetExpr->dim !== null - ) { - $varType = $readType($issetExpr->var); - if (!$varType instanceof MixedType) { - $dimType = $readType($issetExpr->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $constantArrays = $varType->getConstantArrays(); - $typesToRemove = []; - foreach ($constantArrays as $constantArray) { - $hasOffset = $constantArray->hasOffsetValueType($dimType); - if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { - continue; - } - - $typesToRemove[] = $constantArray; - } - - if ($typesToRemove !== []) { - $typeToRemove = TypeCombinator::union(...$typesToRemove); - - $result = $this->defaultNarrowingHelper->createForSubject( - $issetExpr->var, - $typeToRemove, - TypeSpecifierContext::createFalse(), - $s, - )->setRootExpr($rootExpr); - - if ($s->hasExpressionType($issetExpr->var)->maybe()) { - $result = $result->unionWith( - $this->defaultNarrowingHelper->createForSubject( - new IssetExpr($issetExpr->var), - new NullType(), - TypeSpecifierContext::createTruthy(), - $s, - )->setRootExpr($rootExpr), - ); - } - - return $result; - } - } - } - } - - return new SpecifiedTypes(); - } - - } From 805332212d7f23869337d82ea35e25a59d4712c8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 17:08:40 +0200 Subject: [PATCH 266/378] Compose empty() narrowing through the disjunction helper empty($x) narrows like !isset($x) || !$x. The synthesized BooleanOr(BooleanNot(Isset_), BooleanNot(expr)) re-walked per narrowing ask is replaced by specifyDisjunction() over composed operand closures: the isset side reads the captured chain results and the issetability - with the type-aware null verdict, because the disjunction's bool shortcut must see maybe for nullable subjects or the isset branch drops and null is lost from the truthy narrowing - and the truthiness side reads the stored result with a negated context. The fabricated nodes survive only as holder print keys. --- src/Analyser/ExprHandler/EmptyHandler.php | 84 +++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 44a03927041..5b9419cf8a1 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -11,6 +10,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; @@ -34,6 +34,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -53,6 +54,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); + $chainResults = []; + $this->defaultNarrowingHelper->captureChainResults($expr->expr, $storage, $chainResults); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new EmptyExpressionNode($expr, $exprResult), $beforeScope, $storage, $context); return $this->expressionResultFactory->create( @@ -71,16 +75,84 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver): SpecifiedTypes { $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); } - return $this->defaultNarrowingHelper->specifyTypesForNode($s, new BooleanOr( - new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), - new Expr\BooleanNot($expr->expr), - ), $context)->setRootExpr($expr); + // empty($x) narrows like !isset($x) || !$x, composed through the + // disjunction helper - the fabricated nodes are only printed + // into holder keys, never walked + $issetNode = new Expr\Isset_([$expr->expr]); + $notIssetNode = new Expr\BooleanNot($issetNode); + $notExprNode = new Expr\BooleanNot($expr->expr); + + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $chainResults, $expr, $exprResult, $issetNode, $notIssetNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIssetNode, $ctx); + } + $negated = $ctx->negate(); + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope, $nodeScopeResolver); + if (!$negated->true()) { + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($scope, $expr->expr, $exprResult, $readType, $negated, $issetNode); + } + + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $expr->expr, $readType, $issetNode, $negated); + }; + $leftType = static function (MutatingScope $scope) use ($exprResult): Type { + $result = $exprResult->getIssetabilityResolution($scope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }; + $rightTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($exprResult, $notExprNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notExprNode, $ctx); + } + + return $exprResult->getSpecifiedTypesForScope($scope, $ctx->negate()); + }; + $rightType = static function (MutatingScope $scope) use ($exprResult): Type { + $bool = $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted)->toBoolean(); + if ($bool->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($bool->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + + $leftTruthyScope = $s->applySpecifiedTypes($leftTypes($s, TypeSpecifierContext::createTruthy())); + $leftFalseyScope = $s->applySpecifiedTypes($leftTypes($s, TypeSpecifierContext::createFalsey())); + $rightTruthyScope = $leftFalseyScope->applySpecifiedTypes($rightTypes($leftFalseyScope, TypeSpecifierContext::createTruthy())); + + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $notIssetNode, + $leftTypes, + $leftType, + $leftTruthyScope, + $leftFalseyScope, + $notExprNode, + $rightTypes, + $rightType, + $rightTruthyScope, + )->setRootExpr($expr); }, ); } From d8910d6f251a85ed60e853241db48330c93e0e7c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 17:29:11 +0200 Subject: [PATCH 267/378] Compose ternary narrowing through the boolean helpers cond ? if : else narrows like (cond && if) || (!cond && else). The synthesized BooleanOr/BooleanAnd tree that took the on-demand bridge per narrowing ask is replaced by specifyDisjunction() over two composed conjunctions built from the walk's cond/if/else results; the fabricated nodes survive only as holder print keys. Nested-ternary conditions keep the default narrowing. --- src/Analyser/ExprHandler/TernaryHandler.php | 127 ++++++++++++++++++-- 1 file changed, 115 insertions(+), 12 deletions(-) diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index c214036ee03..cf4244256ac 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -12,12 +12,15 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Type\BooleanType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use WeakMap; @@ -37,6 +40,7 @@ final class TernaryHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { $this->capturedResults = new WeakMap(); @@ -164,26 +168,125 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseType, ); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver): SpecifiedTypes { if ($expr->cond instanceof Ternary || $context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - if ($expr->if !== null) { - $conditionExpr = new BooleanOr( - new BooleanAnd($expr->cond, $expr->if), - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), - ); - } else { - $conditionExpr = new BooleanOr( + // cond ? if : else narrows like (cond && if) || (!cond && else), + // composed from the walk's results through the boolean helpers - + // the fabricated nodes are only printed into holder keys + $notCondNode = new Expr\BooleanNot($expr->cond); + + $condTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx); + $condType = static fn (MutatingScope $scope): Type => $ternaryCondResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $notCondTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx->negate()); + $notCondType = static function (MutatingScope $scope) use ($ternaryCondResult): Type { + $bool = $ternaryCondResult->getTypeOnScope($scope, $scope->nativeTypesPromoted)->toBoolean(); + if ($bool->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($bool->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + $andVerdict = static function (callable $left, callable $right): callable { + return static function (MutatingScope $scope) use ($left, $right): Type { + $leftBool = $left($scope)->toBoolean(); + $rightBool = $right($scope)->toBoolean(); + if ($leftBool->isFalse()->yes() || $rightBool->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + if ($leftBool->isTrue()->yes() && $rightBool->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + }; + $elseTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $elseResult->getSpecifiedTypesForScope($scope, $ctx); + $elseType = static fn (MutatingScope $scope): Type => $elseResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + + $condTruthyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createTruthy())); + $condFalseyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createFalsey())); + + // right disjunct: !cond && else + $bNode = new BooleanAnd($notCondNode, $expr->else); + $elseFalseyOnCondFalseyScope = $condFalseyScope->applySpecifiedTypes($elseTypes($condFalseyScope, TypeSpecifierContext::createFalsey())); + $bTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $bNode, + $notCondNode, + $notCondTypes, + $condFalseyScope, + $condTruthyScope, + $expr->else, + $elseTypes, + $elseFalseyOnCondFalseyScope, + ); + $bType = $andVerdict($notCondType, $elseType); + $bTruthyScope = $s->applySpecifiedTypes($bTypes($s, TypeSpecifierContext::createTruthy())); + + if ($ifResult !== null && $expr->if !== null) { + // left disjunct: cond && if + $aNode = new BooleanAnd($expr->cond, $expr->if); + $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); + $ifType = static fn (MutatingScope $scope): Type => $ifResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $ifFalseyOnCondTruthyScope = $condTruthyScope->applySpecifiedTypes($ifTypes($condTruthyScope, TypeSpecifierContext::createFalsey())); + $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $aNode, $expr->cond, - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), + $condTypes, + $condTruthyScope, + $condFalseyScope, + $expr->if, + $ifTypes, + $ifFalseyOnCondTruthyScope, ); + $aType = $andVerdict($condType, $ifType); + $aTruthyScope = $s->applySpecifiedTypes($aTypes($s, TypeSpecifierContext::createTruthy())); + $aFalseyScope = $s->applySpecifiedTypes($aTypes($s, TypeSpecifierContext::createFalsey())); + + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $aNode, + $aTypes, + $aType, + $aTruthyScope, + $aFalseyScope, + $bNode, + $bTypes, + $bType, + $bTruthyScope, + )->setRootExpr($expr); } - // the synthetic condition takes the on-demand bridge; its real - // subnodes answer from stored results - return $s->obtainResultForNode($conditionExpr)->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + // short ternary: cond || (!cond && else) + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $expr->cond, + $condTypes, + $condType, + $condTruthyScope, + $condFalseyScope, + $bNode, + $bTypes, + $bType, + $bTruthyScope, + )->setRootExpr($expr); }, ); } From 8b4ab410d8b54f0fbb67c386c89f8b1d9a6c2aa2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 17:41:38 +0200 Subject: [PATCH 268/378] Compose cast narrowing through the loose-equality helper (bool)/(int)/(float) casts narrow like loose comparisons of the inner expression against true/0/0.0. specifyEqual() is called with the walk's result and a fabricated literal whose type now resolves statically through literalType() - literal sides no longer read their ExpressionResult, so a stand-in result is safe. The synthetic Equal/NotEqual on-demand route stays as the fallback seam. --- src/Analyser/ExprHandler/CastHandler.php | 27 +++++++++++++- .../Helper/IdenticalNarrowingHelper.php | 36 ++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index f472b261e83..b5d08b52240 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -38,6 +39,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, ) { } @@ -74,7 +76,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + // a cast's truthiness is a loose comparison of the inner + // expression - composed from its result; the fabricated + // literal is only printed into entries, never walked + if (($expr instanceof Cast\Bool_ || $expr instanceof Cast\Int_ || $expr instanceof Cast\Double) && !$context->null()) { + $evaluationScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if ($expr instanceof Cast\Bool_) { + $literal = new ConstFetch(new FullyQualified('true')); + $equalContext = $context; + } elseif ($expr instanceof Cast\Int_) { + $literal = new Int_(0); + $equalContext = $context->negate(); + } else { + $literal = new Float_(0.0); + $equalContext = $context->negate(); + } + + // the literal side never reads its stand-in result + $types = $this->identicalNarrowingHelper->specifyEqual($nodeScopeResolver, $expr->expr, $literal, $exprResult, $exprResult, $equalContext, $evaluationScope); + if ($types !== null) { + return $types->setRootExpr($expr); + } + } + if ($expr instanceof Cast\Bool_) { return $s->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index b81eabe6e19..cd99649f41d 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -190,7 +190,7 @@ private function specifyAgainstScalarLiteral( $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if ($unwrappedSubject instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); if ($familyTypes === null) { return null; } @@ -206,7 +206,7 @@ private function specifyAgainstScalarLiteral( return null; } - $constantType = $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $constantType = $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); if (count($constantType->getFiniteTypes()) !== 1) { // a class constant does not have to be single-valued return null; @@ -470,8 +470,8 @@ public function specifyEqual( $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; - $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); $leftScalarValues = $leftType->getConstantScalarValues(); $rightScalarValues = $rightType->getConstantScalarValues(); @@ -982,6 +982,34 @@ private function specifyFuncCallFamilies( return false; } + /** The static type of a literal node - no result or scope needed. */ + private function literalType(Expr $expr): ?Type + { + if ($expr instanceof Scalar\Int_) { + return new ConstantIntegerType($expr->value); + } + if ($expr instanceof Scalar\Float_) { + return new ConstantFloatType($expr->value); + } + if ($expr instanceof Scalar\String_) { + return new ConstantStringType($expr->value); + } + if ($expr instanceof Expr\ConstFetch) { + $name = $expr->name->toLowerString(); + if ($name === 'true') { + return new ConstantBooleanType(true); + } + if ($name === 'false') { + return new ConstantBooleanType(false); + } + if ($name === 'null') { + return new NullType(); + } + } + + return null; + } + private function isScalarLiteral(Expr $expr): bool { if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\String_ || $expr instanceof Scalar\Float_) { From 52ad10e430477e513bf920f88bf36e0847811e84 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 17:47:06 +0200 Subject: [PATCH 269/378] Read tracked foreach and @var types without synthetic pricing The foreach continue-scope dim fetch is tracked by enterForeach and the @var reassignment's native type is plain variable state - readTypeOfMaybeStored's fast paths answer both without pricing synthetic nodes. --- src/Analyser/NodeScopeResolver.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3c81d719c4d..3913cb079e3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1708,8 +1708,10 @@ public function processStmtNode( foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { if ($keyVarExpr !== null) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $keyVarExpr); - $dimFetchType = $this->processSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType)->getTypeOnScope($scopeWithIterableValueType, false); - $dimFetchNativeType = $this->processSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($scopeWithIterableValueType, true); + // enterForeach tracks this exact dim fetch - the tracked-holder + // fast path answers without pricing the synthetic node + $dimFetchType = $this->readTypeOfMaybeStored($arrayExprDimFetch, $scopeWithIterableValueType); + $dimFetchNativeType = $this->readTypeOfMaybeStored($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the // two describe the same element for a given iteration. If the value var @@ -4838,7 +4840,8 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $this->processSyntheticOnDemand($variableNode, $scope->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($scope, true), + // a plain variable read is scope state - no synthetic pricing + $this->readTypeOfMaybeStored($variableNode, $scope->doNotTreatPhpDocTypesAsCertain()), $certainty, ); } From d1e0a4963d98870a4ab08d9ca1f22c2f0dcc24c4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 18:36:56 +0200 Subject: [PATCH 270/378] Capture call-argument results for the composed comparison narrowing The function-family compositions read their argument results from the asking scope's current storage stack - ask-time state that differs between main-pass and post-walk asks and blocks memoizing the narrowing per TypeSpecifierContext. The seams now capture the first argument's result at create time - the result, not the storage, keeping retention bounded (a storage capture exhausted worker memory; a weak reference died before post-walk asks). Fixes a TypeSpecifierTest known-red where the ask-time read missed the count() argument result. --- src/Analyser/ExprHandler/AssignHandler.php | 5 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 9 ++- src/Analyser/ExprHandler/CastHandler.php | 6 +- .../Helper/IdenticalNarrowingHelper.php | 74 +++++++++++++------ 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 24d4ca554be..9abfd8172b1 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -523,6 +523,7 @@ public function processAssignVar( } $storedAssignedExprResult = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($assignedExpr); + $assignedArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($assignedExpr, $storage); $truthyType = TypeCombinator::removeFalsey($type); // Value comparison, not identity: remove() happens to hand back the very same @@ -577,14 +578,14 @@ public function processAssignVar( : new BooleanType(); $notIdenticalSpecifiedTypes = $storedAssignedExprResult !== null - ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createFalse(), $scope, $identicalTypeCallback) + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createFalse(), $scope, $assignedArgResult, $identicalTypeCallback) : null; $notIdenticalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); $identicalSpecifiedTypes = $storedAssignedExprResult !== null - ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createTrue(), $scope, $identicalTypeCallback) + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createTrue(), $scope, $assignedArgResult, $identicalTypeCallback) : null; $identicalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index a5ccc5b38b3..f8c8d248ebb 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -111,6 +111,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $scope = $rightResult->getScope(); + $leftArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->left, $storage); + $rightArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->right, $storage); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -252,7 +255,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult): SpecifiedTypes { $resultFor = static fn (Expr $e): ?ExpressionResult => $e === $expr->left ? $leftResult : ($e === $expr->right ? $rightResult : null); if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { // `!==` narrowing is the `===` narrowing in the negated context - @@ -268,6 +271,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the narrowing composes on the evaluation scope; only the // asked flavour comes from the asking scope $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $leftArgResult, + $rightArgResult, // the comparison's own verdict, in Identical semantics static function () use ($nodeScopeResolver, $expr, $scope): Type { $ownType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); @@ -316,6 +321,8 @@ static function () use ($nodeScopeResolver, $expr, $scope): Type { $rightResult, $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $leftArgResult, + $rightArgResult, ); if ($newWorldTypes !== null) { return $newWorldTypes->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index b5d08b52240..8439b5a394a 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -55,6 +55,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); + $subjectArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->expr, $storage); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -76,7 +78,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope, $subjectArgResult): SpecifiedTypes { // a cast's truthiness is a loose comparison of the inner // expression - composed from its result; the fabricated // literal is only printed into entries, never walked @@ -94,7 +96,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } // the literal side never reads its stand-in result - $types = $this->identicalNarrowingHelper->specifyEqual($nodeScopeResolver, $expr->expr, $literal, $exprResult, $exprResult, $equalContext, $evaluationScope); + $types = $this->identicalNarrowingHelper->specifyEqual($nodeScopeResolver, $expr->expr, $literal, $exprResult, $exprResult, $equalContext, $evaluationScope, $subjectArgResult, null); if ($types !== null) { return $types->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index cd99649f41d..fea46dfc221 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -7,6 +7,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Scalar; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\RicherScopeGetTypeHelper; @@ -83,6 +84,8 @@ public function specifyIdentical( ExpressionResult $rightResult, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, callable $identicalTypeCallback, ): ?SpecifiedTypes { @@ -101,12 +104,12 @@ public function specifyIdentical( $subject = $left; $subjectResult = $leftResult; } else { - $types = $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + $types = $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); if ($types !== null) { return $types; } - return $this->specifyGeneral($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyGeneral($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } if ($constantName !== 'null' && !$this->isSubjectCoveredAgainstConstant($subject)) { @@ -171,6 +174,8 @@ private function specifyAgainstScalarLiteral( ExpressionResult $rightResult, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, callable $identicalTypeCallback, ): ?SpecifiedTypes { @@ -190,7 +195,7 @@ private function specifyAgainstScalarLiteral( $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; if ($unwrappedSubject instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope, $subject === $left ? $leftArgResult : $rightArgResult); if ($familyTypes === null) { return null; } @@ -347,6 +352,8 @@ private function specifyGeneral( ExpressionResult $rightResult, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, callable $identicalTypeCallback, ): ?SpecifiedTypes { @@ -380,9 +387,9 @@ private function specifyGeneral( // normalization that moved the call to the left if ($unwrappedLeft instanceof Expr\FuncCall || $unwrappedRight instanceof Expr\FuncCall) { if ($unwrappedLeft instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope, $leftArgResult); } else { - $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope, $rightArgResult); } if ($familyTypes === null) { return null; @@ -460,6 +467,8 @@ public function specifyEqual( ExpressionResult $rightResult, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, ): ?SpecifiedTypes { if ($context->null()) { @@ -476,12 +485,12 @@ public function specifyEqual( $leftScalarValues = $leftType->getConstantScalarValues(); $rightScalarValues = $rightType->getConstantScalarValues(); if (count($leftScalarValues) === 1 && !$unwrappedRight instanceof Expr\ConstFetch) { - $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $right, $rightResult, $leftScalarValues[0], $leftType, $rightType, $context, $evaluationScope, $identicalTypeCallback); + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $right, $rightResult, $leftScalarValues[0], $leftType, $rightType, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); if ($constantSideTypes !== false) { return $constantSideTypes; } } elseif (count($rightScalarValues) === 1 && !$unwrappedLeft instanceof Expr\ConstFetch) { - $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $left, $leftResult, $rightScalarValues[0], $rightType, $leftType, $context, $evaluationScope, $identicalTypeCallback); + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $left, $leftResult, $rightScalarValues[0], $rightType, $leftType, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); if ($constantSideTypes !== false) { return $constantSideTypes; } @@ -492,11 +501,11 @@ public function specifyEqual( $leftBool = $leftType->toBoolean(); if (($leftBool->isTrue()->yes() || $leftBool->isFalse()->yes()) && $rightType->isBoolean()->yes()) { // the literal side of the delegation needs no result; the subject side is the right operand - return $this->specifyIdentical($nodeScopeResolver, new Expr\ConstFetch(new Name($leftBool->isTrue()->yes() ? 'true' : 'false')), $right, $rightResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, new Expr\ConstFetch(new Name($leftBool->isTrue()->yes() ? 'true' : 'false')), $right, $rightResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } $rightBool = $rightType->toBoolean(); if (($rightBool->isTrue()->yes() || $rightBool->isFalse()->yes()) && $leftType->isBoolean()->yes()) { - return $this->specifyIdentical($nodeScopeResolver, $left, new Expr\ConstFetch(new Name($rightBool->isTrue()->yes() ? 'true' : 'false')), $leftResult, $leftResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, $left, new Expr\ConstFetch(new Name($rightBool->isTrue()->yes() ? 'true' : 'false')), $leftResult, $leftResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } // an empty constant array equals only empty countables @@ -514,7 +523,7 @@ public function specifyEqual( || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) ) { - return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } $leftExprString = $this->exprPrinter->printExpr($left); @@ -550,6 +559,7 @@ public function specifyIdenticalAgainstType( Type $constantType, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $subjectArgResult, callable $identicalTypeCallback, ): ?SpecifiedTypes { @@ -580,7 +590,7 @@ public function specifyIdenticalAgainstType( } if ($unwrappedSubject instanceof Expr\FuncCall) { - $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantType, $context, $evaluationScope); + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantType, $context, $evaluationScope, $subjectArgResult); if ($familyTypes === null) { return null; } @@ -635,6 +645,8 @@ private function specifyEqualAgainstConstantSide( Type $otherType, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, callable $identicalTypeCallback, ): SpecifiedTypes|false|null { @@ -692,10 +704,10 @@ private function specifyEqualAgainstConstantSide( ) { $funcName = $unwrappedSubject->name->toLowerString(); if (in_array($funcName, ['gettype', 'get_class', 'get_debug_type'], true) && $constantType->isString()->yes()) { - return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } if ($context->true() && $funcName === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { - return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } } if ( @@ -704,7 +716,7 @@ private function specifyEqualAgainstConstantSide( && $unwrappedSubject->name->toLowerString() === 'class' && $constantType->isString()->yes() ) { - return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } return false; @@ -726,6 +738,7 @@ private function specifyFuncCallFamilies( Type $constantType, TypeSpecifierContext $context, MutatingScope $evaluationScope, + ?ExpressionResult $argResult, ): SpecifiedTypes|false|null { if (!($call->name instanceof Name) || $call->isFirstClassCallable() || !isset($call->getArgs()[0])) { @@ -752,8 +765,7 @@ private function specifyFuncCallFamilies( $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { $argExpr = $call->getArgs()[0]->value; - $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); - if ($argResult === null) { + if ($argResult === null) { return null; } if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { @@ -778,8 +790,7 @@ private function specifyFuncCallFamilies( $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { $argExpr = $call->getArgs()[0]->value; - $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); - if ($argResult === null) { + if ($argResult === null) { return null; } $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); @@ -813,8 +824,7 @@ private function specifyFuncCallFamilies( ) { if ($context->truthy() && $constantType->isNonEmptyString()->yes()) { $argExpr = $call->getArgs()[0]->value; - $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); - if ($argResult === null) { + if ($argResult === null) { return null; } $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); @@ -859,8 +869,7 @@ private function specifyFuncCallFamilies( return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); } - $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); - if ($argResult === null) { + if ($argResult === null) { return null; } $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); @@ -921,8 +930,7 @@ private function specifyFuncCallFamilies( } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { - $argResult = $evaluationScope->getCurrentExpressionResultStorage()?->findExpressionResult($argExpr); - if ($argResult === null) { + if ($argResult === null) { return null; } if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { @@ -982,6 +990,24 @@ private function specifyFuncCallFamilies( return false; } + /** + * The first argument's stored result of a (possibly remembered) call + * operand, captured by the seams at create time - the composed function + * families read it instead of the asking scope's storage stack (ask-time + * state that differs between main-pass and post-walk asks and would + * break memoizing the narrowing per context). Capturing the result, not + * the storage, keeps retention bounded. + */ + public function captureFirstArgResult(Expr $side, ExpressionResultStorage $storage): ?ExpressionResult + { + $unwrapped = $side instanceof AlwaysRememberedExpr ? $side->getExpr() : $side; + if (!$unwrapped instanceof Expr\FuncCall || $unwrapped->isFirstClassCallable() || !isset($unwrapped->getArgs()[0])) { + return null; + } + + return $storage->findExpressionResult($unwrapped->getArgs()[0]->value); + } + /** The static type of a literal node - no result or scope needed. */ private function literalType(Expr $expr): ?Type { From f23e3efdcee6d2d7b2f4b35cba1078699f4c3a10 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 18:43:39 +0200 Subject: [PATCH 271/378] Narrow bool-typed identity comparisons in IdenticalNarrowingHelper The bool terminal extracts into specifyAgainstBool(), loses its call-subject guard (the sentinel-comparison trap it guarded against flows through specifyIdenticalAgainstType, which never guarded), and now also serves sides whose TYPE is a constant bool - match (true) arms and bool class constants - the composed form of the old constant-binary handling. Kills roughly 209 of the 276 old-helper fallback hits in the nsrt census. --- .../Helper/IdenticalNarrowingHelper.php | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index fea46dfc221..232d4b58fa4 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -104,6 +104,20 @@ public function specifyIdentical( $subject = $left; $subjectResult = $leftResult; } else { + // a side whose TYPE is a constant bool (match (true) arms, bool + // class constants) compares like the literal - the old + // constant-binary handling, composed + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (($leftType->isTrue()->yes() || $leftType->isFalse()->yes()) && !$unwrappedRight instanceof Expr\ConstFetch) { + return $this->specifyAgainstBool($right, $rightResult, $leftType->isTrue()->yes(), $context, $evaluationScope); + } + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (($rightType->isTrue()->yes() || $rightType->isFalse()->yes()) && !$unwrappedLeft instanceof Expr\ConstFetch) { + return $this->specifyAgainstBool($left, $leftResult, $rightType->isTrue()->yes(), $context, $evaluationScope); + } + $types = $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); if ($types !== null) { return $types; @@ -112,10 +126,6 @@ public function specifyIdentical( return $this->specifyGeneral($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); } - if ($constantName !== 'null' && !$this->isSubjectCoveredAgainstConstant($subject)) { - return null; - } - if ($constantName === 'null') { // deliberately NOT guarded by specifyDecidedComparison(): a decided // null comparison still emits its subtraction entry so assign-time @@ -131,14 +141,27 @@ public function specifyIdentical( ); } - // a bool literal pins the constant through the entries and runs the - // subject's own narrowing in the matching bool context - identity, - // not truthiness: `=== false` is the false context, not falsey + return $this->specifyAgainstBool($subject, $subjectResult, $constantName === 'true', $context, $evaluationScope); + } + + /** + * A bool constant pins itself through the entries and runs the subject's + * own narrowing in the matching bool context - identity, not truthiness: + * `=== false` is the false context, not falsey. + */ + private function specifyAgainstBool( + Expr $subject, + ExpressionResult $subjectResult, + bool $value, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ): SpecifiedTypes + { $types = $this->defaultNarrowingHelper->createSubjectTypes( $evaluationScope, $subject, $subjectResult, - new ConstantBooleanType($constantName === 'true'), + new ConstantBooleanType($value), $context, ); @@ -150,7 +173,7 @@ public function specifyIdentical( return $types; } - $boolContext = $constantName === 'true' ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + $boolContext = $value ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); return $types->unionWith($subjectResult->getSpecifiedTypesForScope( $evaluationScope, @@ -374,14 +397,6 @@ private function specifyGeneral( $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - // a side whose TYPE is a constant bool delegates into the subject's - // own bool-context narrowing - only the literal form is composed - foreach ([$leftType, $rightType] as $sideType) { - if ($sideType->isTrue()->yes() || $sideType->isFalse()->yes()) { - return null; - } - } - // a single call side runs the family compositions with the other // side's TYPE as the constant - the composed form of the old // normalization that moved the call to the left From 176195eeac846aa48d0180c855cb77bbcb14f854 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 18:49:04 +0200 Subject: [PATCH 272/378] Merge both directions of call-vs-call identity comparisons fn1() === fn2() runs the family compositions in both normalized directions and unions the answers. count-vs-count flows a decided size across through CountNarrowingHelper, or makes both arguments non-empty when either side is. The last call-vs-call fallbacks to the old helper die. --- .../Helper/IdenticalNarrowingHelper.php | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 232d4b58fa4..9acb0b38ec6 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -383,19 +383,65 @@ private function specifyGeneral( $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; - // fn1() === fn2() merges both normalized directions - not composed; // ::class fetches narrow by TYPE-based constants in old-world blocks - if ($unwrappedLeft instanceof Expr\FuncCall && $unwrappedRight instanceof Expr\FuncCall) { - return null; - } foreach ([$unwrappedLeft, $unwrappedRight] as $side) { if ($side instanceof Expr\ClassConstFetch && $side->class instanceof Expr) { return null; } } - $leftType = $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); - $rightType = $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + // fn1() === fn2() merges both normalized directions + if ($unwrappedLeft instanceof Expr\FuncCall && $unwrappedRight instanceof Expr\FuncCall) { + // count($a) === count($b): a decided size flows across; otherwise + // one non-empty side makes both non-empty + if ( + $context->true() + && $unwrappedLeft->name instanceof Name && in_array($unwrappedLeft->name->toLowerString(), ['count', 'sizeof'], true) && !$unwrappedLeft->isFirstClassCallable() && isset($unwrappedLeft->getArgs()[0]) + && $unwrappedRight->name instanceof Name && in_array($unwrappedRight->name->toLowerString(), ['count', 'sizeof'], true) && !$unwrappedRight->isFirstClassCallable() && isset($unwrappedRight->getArgs()[0]) + ) { + if ($leftArgResult === null || $rightArgResult === null) { + return null; + } + $rightArgType = $rightArgResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $countTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedRight, $rightArgType, $leftType, $context, $evaluationScope, $unwrappedRight); + if ($countTypes !== null) { + return $countTypes; + } + + $leftArgType = $leftArgResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if ( + $leftArgType->isArray()->yes() + && $rightArgType->isArray()->yes() + && !$rightType->isConstantScalarValue()->yes() + && ($leftArgType->isIterableAtLeastOnce()->yes() || $rightArgType->isIterableAtLeastOnce()->yes()) + ) { + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeft->getArgs()[0]->value, new NonEmptyArrayType(), $context, $evaluationScope)->unionWith( + $this->defaultNarrowingHelper->createForSubject($unwrappedRight->getArgs()[0]->value, new NonEmptyArrayType(), $context, $evaluationScope), + ); + } + } + + $leftDirection = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope, $leftArgResult); + $rightDirection = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope, $rightArgResult); + if ($leftDirection === null || $rightDirection === null) { + return null; + } + $merged = null; + if ($leftDirection !== false) { + $merged = $leftDirection; + } + if ($rightDirection !== false) { + $merged = $merged !== null ? $merged->unionWith($rightDirection) : $rightDirection; + } + if ($merged !== null) { + return $merged; + } + + // neither family matched - the generic tail below pins both sides + } // a single call side runs the family compositions with the other // side's TYPE as the constant - the composed form of the old From 2dd0937dc20d2a0d8652b991ab4584e06d7dd760 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 18:59:12 +0200 Subject: [PATCH 273/378] Compose the last identity-comparison tails get_parent_class outside the true context pins generically instead of falling back; a union of gettype type names narrows by the intersection of the per-name narrowings; a $a::class side against a single known class name pins the class to a final ObjectType, and non-constant or empty sides narrow generically. The old-helper fallback census over the whole nsrt suite is now zero. --- .../Helper/IdenticalNarrowingHelper.php | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 9acb0b38ec6..39b1515f28a 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -383,10 +383,32 @@ private function specifyGeneral( $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; - // ::class fetches narrow by TYPE-based constants in old-world blocks - foreach ([$unwrappedLeft, $unwrappedRight] as $side) { - if ($side instanceof Expr\ClassConstFetch && $side->class instanceof Expr) { - return null; + // a `$a::class` side falls back only where the old instanceof-style + // blocks would fire: a true context with a single class-name string + // on the other side; everything else narrows generically + if ($context->true()) { + foreach ([ + [$unwrappedLeft, $left, $leftResult, $rightResult], + [$unwrappedRight, $right, $rightResult, $leftResult], + ] as [$sideUnwrapped, $side, $sideResult, $otherResult]) { + if (!($sideUnwrapped instanceof Expr\ClassConstFetch) || !($sideUnwrapped->class instanceof Expr)) { + continue; + } + $otherStrings = $otherResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->getConstantStrings(); + if (count($otherStrings) !== 1 || $otherStrings[0]->getValue() === '') { + continue; + } + if (!$this->reflectionProvider->hasClass($otherStrings[0]->getValue())) { + // an unknown class narrows like instanceof - not composed + return null; + } + + return $this->defaultNarrowingHelper->createForSubject( + $sideUnwrapped->class, + new ObjectType($otherStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($otherStrings[0]->getValue())->asFinal()), + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $side, $sideResult, $otherStrings[0], $context)); } } @@ -844,9 +866,7 @@ private function specifyFuncCallFamilies( } // a known parent class narrows the argument to the child side of it - if ( - $call->name->toLowerString() === 'get_parent_class' - ) { + if ($call->name->toLowerString() === 'get_parent_class') { if ($context->true()) { $constantStrings = $constantType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { @@ -870,7 +890,7 @@ private function specifyFuncCallFamilies( } } - return null; + // other contexts and non-single class names only pin the call } // a string function whose result is a non-empty literal had a @@ -1014,16 +1034,37 @@ private function specifyFuncCallFamilies( $call->name->toLowerString() === 'gettype' ) { $constantStrings = $constantType->getConstantStrings(); - if (count($constantStrings) !== 1) { - return null; + if (count($constantStrings) > 1) { + // a union of type names narrows by the intersection of the + // per-name narrowings + $intersectedTypes = null; + foreach ($constantStrings as $constantString) { + $mapped = $this->getTypeFromGettypeStringValue($constantString->getValue()); + if ($mapped === null) { + continue; + } + $one = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantString, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $mapped, $context, $evaluationScope), + ); + $intersectedTypes = $intersectedTypes === null ? $one : $intersectedTypes->intersectWith($one); + } + if ($intersectedTypes !== null) { + return $intersectedTypes; + } + + // no known type names - only pin the call } - $gettypeNarrowedType = $this->getTypeFromGettypeStringValue($constantStrings[0]->getValue()); - if ($gettypeNarrowedType !== null) { - return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( - $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), - ); + if (count($constantStrings) === 1) { + $gettypeNarrowedType = $this->getTypeFromGettypeStringValue($constantStrings[0]->getValue()); + if ($gettypeNarrowedType !== null) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), + ); + } + // an unknown type-name string only pins the call itself below } - // an unknown type-name string only pins the call itself below + + // a non-constant string side only pins the call } // get_class($o) === 'Foo' pins $o to a final Foo when the comparison From 847ae5ef9607339689a2f571844c95f4993f2b1c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 19:10:29 +0200 Subject: [PATCH 274/378] Delete the old-world equality narrowing Every ===/!==/==/!= shape composes from operand results in IdenticalNarrowingHelper. The fallback census over the whole test corpus showed the old path producing narrowing nowhere, so the seam's null now means no shape-specific narrowing - unknown-class ::class sides and null-context asks - and applies the default truthy/falsey narrowing. EqualityTypeSpecifyingHelper, its resultFor plumbing, the synthetic-Identical fallback routes and its baseline entries die. --- phpstan-baseline.neon | 12 - src/Analyser/ExprHandler/BinaryOpHandler.php | 122 +-- .../Helper/EqualityTypeSpecifyingHelper.php | 947 ------------------ .../Helper/IdenticalNarrowingHelper.php | 7 +- 4 files changed, 48 insertions(+), 1040 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b88fe29503d..7d47f7da20a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,18 +36,6 @@ parameters: count: 2 path: src/Analyser/ExprHandler/BinaryOpHandler.php - - - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' - identifier: phpstanApi.instanceofType - count: 2 - path: src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php - - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 3 - path: src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index f8c8d248ebb..7e8630d1207 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -20,7 +20,6 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\CountNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; -use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; @@ -68,7 +67,6 @@ public function __construct( private PhpVersion $phpVersion, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, - private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, private IdenticalNarrowingHelper $identicalNarrowingHelper, private CountNarrowingHelper $countNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, @@ -256,95 +254,65 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }, specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult): SpecifiedTypes { - $resultFor = static fn (Expr $e): ?ExpressionResult => $e === $expr->left ? $leftResult : ($e === $expr->right ? $rightResult : null); if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { // `!==` narrowing is the `===` narrowing in the negated context - // no synthetic Identical node. A null context never negates. - if (!($context->null() && $expr instanceof BinaryOp\NotIdentical)) { - $newWorldTypes = $this->identicalNarrowingHelper->specifyIdentical( - $nodeScopeResolver, - $expr->left, - $expr->right, - $leftResult, - $rightResult, - $expr instanceof BinaryOp\NotIdentical ? $context->negate() : $context, - // the narrowing composes on the evaluation scope; only the - // asked flavour comes from the asking scope - $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, - $leftArgResult, - $rightArgResult, - // the comparison's own verdict, in Identical semantics - static function () use ($nodeScopeResolver, $expr, $scope): Type { - $ownType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); - if ($expr instanceof BinaryOp\NotIdentical) { - if ($ownType->isTrue()->yes()) { - return new ConstantBooleanType(false); - } - if ($ownType->isFalse()->yes()) { - return new ConstantBooleanType(true); - } - } - - return $ownType; - }, - ); - if ($newWorldTypes !== null) { - return $newWorldTypes->setRootExpr($expr); - } + if ($context->null() && $expr instanceof BinaryOp\NotIdentical) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - } - if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context, $resultFor); - } + $newWorldTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotIdentical ? $context->negate() : $context, + // the narrowing composes on the evaluation scope; only the + // asked flavour comes from the asking scope + $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $leftArgResult, + $rightArgResult, + // the comparison's own verdict, in Identical semantics + static function () use ($nodeScopeResolver, $expr, $scope): Type { + $ownType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + if ($expr instanceof BinaryOp\NotIdentical) { + if ($ownType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($ownType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + } - if ($expr instanceof BinaryOp\NotIdentical) { - if ($context->null()) { - return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - } + return $ownType; + }, + ); - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new BinaryOp\Identical($expr->left, $expr->right), - $context->negate(), - )->setRootExpr($expr); + // null = no shape-specific narrowing (unknown-class ::class, + // null-context asks) - the default is all that remains + return ($newWorldTypes ?? $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))->setRootExpr($expr); } if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { // `!=` narrowing is the `==` narrowing in the negated context - if (!($context->null() && $expr instanceof BinaryOp\NotEqual)) { - $newWorldTypes = $this->identicalNarrowingHelper->specifyEqual( - $nodeScopeResolver, - $expr->left, - $expr->right, - $leftResult, - $rightResult, - $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, - $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, - $leftArgResult, - $rightArgResult, - ); - if ($newWorldTypes !== null) { - return $newWorldTypes->setRootExpr($expr); - } - } - } - - if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context, $resultFor); - } - - if ($expr instanceof BinaryOp\NotEqual) { - // see NotIdentical above - if ($context->null()) { + if ($context->null() && $expr instanceof BinaryOp\NotEqual) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new BinaryOp\Equal($expr->left, $expr->right), - $context->negate(), - )->setRootExpr($expr); + $newWorldTypes = $this->identicalNarrowingHelper->specifyEqual( + $nodeScopeResolver, + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, + $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $leftArgResult, + $rightArgResult, + ); + + return ($newWorldTypes ?? $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))->setRootExpr($expr); } if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php deleted file mode 100644 index ead819fdc6a..00000000000 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ /dev/null @@ -1,947 +0,0 @@ -findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); - if ($expressions !== null) { - $exprNode = $expressions[0]; - $constantType = $expressions[1]; - $otherType = $expressions[2]; - - if (!$context->null() && $constantType->getValue() === null) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType(''), - new ConstantArrayType([], []), - ]; - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === false) { - return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( - $scope->toMutatingScope(), - $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), - )->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === true) { - return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( - $scope->toMutatingScope(), - $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), - )->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === 0 && !$otherType->isInteger()->yes() && !$otherType->isBoolean()->yes()) { - /* There is a difference between php 7.x and 8.x on the equality - * behavior between zero and the empty string, so to be conservative - * we leave it untouched regardless of the language version */ - if ($context->true()) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new StringType(), - ]; - } else { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType('0'), - ]; - } - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === '') { - /* There is a difference between php 7.x and 8.x on the equality - * behavior between zero and the empty string, so to be conservative - * we leave it untouched regardless of the language version */ - if ($context->true()) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType(''), - ]; - } else { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantStringType(''), - ]; - } - return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); - } - - if ( - $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && in_array(strtolower($exprNode->name->toString()), ['gettype', 'get_class', 'get_debug_type'], true) - && isset($exprNode->getArgs()[0]) - && $constantType->isString()->yes() - ) { - return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && $exprNode->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes() - ) { - return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - if ( - $context->true() - && $exprNode instanceof ClassConstFetch - && $exprNode->name instanceof Node\Identifier - && strtolower($exprNode->name->toString()) === 'class' - && $constantType->isString()->yes() - ) { - return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - } - - // the operands were processed during processExpr; read their stored results - // instead of re-walking via Scope::getType(). - $leftType = $nodeScopeResolver->readTypeOfMaybeStored($expr->left, $scope->toMutatingScope()); - $rightType = $nodeScopeResolver->readTypeOfMaybeStored($expr->right, $scope->toMutatingScope()); - - $leftBooleanType = $leftType->toBoolean(); - if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new Expr\BinaryOp\Identical( - new ConstFetch(new Name($leftBooleanType->getValue() ? 'true' : 'false')), - $expr->right, - ), - $context, - )->setRootExpr($expr); - } - - $rightBooleanType = $rightType->toBoolean(); - if ($rightBooleanType instanceof ConstantBooleanType && $leftType->isBoolean()->yes()) { - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new Expr\BinaryOp\Identical( - $expr->left, - new ConstFetch(new Name($rightBooleanType->getValue() ? 'true' : 'false')), - ), - $context, - )->setRootExpr($expr); - } - - if ( - !$context->null() - && $rightType->isArray()->yes() - && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() - ) { - return $this->defaultNarrowingHelper->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); - } - - if ( - !$context->null() - && $leftType->isArray()->yes() - && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() - ) { - return $this->defaultNarrowingHelper->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); - } - - if ( - ($leftType->isString()->yes() && $rightType->isString()->yes()) - || ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) - || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) - || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) - ) { - return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - $leftExprString = $this->exprPrinter->printExpr($expr->left); - $rightExprString = $this->exprPrinter->printExpr($expr->right); - if ($leftExprString === $rightExprString) { - if (!$expr->left instanceof Expr\Variable || !$expr->right instanceof Expr\Variable) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - $leftTypes = $this->defaultNarrowingHelper->createForSubject($expr->left, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createForSubject($expr->right, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - - return $context->true() - ? $leftTypes->unionWith($rightTypes) - : $leftTypes->normalize($scope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($scope, $nodeScopeResolver)); - } - - /** - * @param Closure(Expr): ?ExpressionResult $resultFor - */ - public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes - { - $leftExpr = $expr->left; - $rightExpr = $expr->right; - - // Normalize to: fn() === expr - if ($rightExpr instanceof FuncCall && !$leftExpr instanceof FuncCall) { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( - $rightExpr, - $leftExpr, - ), $scope, $context, $resultFor); - } else { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( - $leftExpr, - $rightExpr, - ), $scope, $context, $resultFor); - } - - // merge result of fn1() === fn2() and fn2() === fn1() - if ($rightExpr instanceof FuncCall && $leftExpr instanceof FuncCall) { - return $specifiedTypes->unionWith( - $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( - $rightExpr, - $leftExpr, - ), $scope, $context, $resultFor), - ); - } - - return $specifiedTypes; - } - - /** - * @param Closure(Expr): ?ExpressionResult $resultFor - */ - private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes - { - $leftExpr = $expr->left; - $rightExpr = $expr->right; - - $unwrappedLeftExpr = $leftExpr; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $unwrappedLeftExpr = $leftExpr->getExpr(); - } - $unwrappedRightExpr = $rightExpr; - if ($rightExpr instanceof AlwaysRememberedExpr) { - $unwrappedRightExpr = $rightExpr->getExpr(); - } - - // the operands and their subexpressions were processed during processExpr; - // read their stored results instead of re-walking via Scope::getType(). - $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $scope->toMutatingScope()); - - $rightType = $getType($rightExpr); - - // (count($a) === $expr) - if ( - !$context->null() - && $unwrappedLeftExpr instanceof FuncCall - && !$unwrappedLeftExpr->isFirstClassCallable() - && count($unwrappedLeftExpr->getArgs()) >= 1 - && $unwrappedLeftExpr->name instanceof Name - && in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true) - && $rightType->isInteger()->yes() - ) { - // count($a) === count($b) - if ( - $context->true() - && $unwrappedRightExpr instanceof FuncCall - && $unwrappedRightExpr->name instanceof Name - && !$unwrappedRightExpr->isFirstClassCallable() - && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) - && count($unwrappedRightExpr->getArgs()) >= 1 - ) { - $argType = $getType($unwrappedRightExpr->getArgs()[0]->value); - $sizeType = $getType($leftExpr); - - $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedRightExpr, $argType, $sizeType, $context, $scope->toMutatingScope(), $expr); - if ($specifiedTypes !== null) { - return $specifiedTypes; - } - - $leftArrayType = $getType($unwrappedLeftExpr->getArgs()[0]->value); - $rightArrayType = $getType($unwrappedRightExpr->getArgs()[0]->value); - if ( - $leftArrayType->isArray()->yes() - && $rightArrayType->isArray()->yes() - && !$rightType->isConstantScalarValue()->yes() - && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) - ) { - $arrayTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr); - return $arrayTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), - ); - } - } - - if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); - } - - $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); - $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); - if ($isZero->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - - if ($context->truthy() && !$argType->isArray()->yes()) { - $newArgType = new UnionType([ - new ObjectType(Countable::class), - new ConstantArrayType([], []), - ]); - } else { - $newArgType = new ConstantArrayType([], []); - } - - return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope, $resultFor)->setRootExpr($expr), - ); - } - - $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedLeftExpr, $argType, $rightType, $context, $scope->toMutatingScope(), $expr); - if ($specifiedTypes !== null) { - if ($leftExpr !== $unwrappedLeftExpr) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - return $specifiedTypes->unionWith($funcTypes); - } - return $specifiedTypes; - } - - if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), - ); - } - - return $funcTypes; - } - } - - // strlen($a) === $b - if ( - !$context->null() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower((string) $unwrappedLeftExpr->name), ['strlen', 'mb_strlen'], true) - && count($unwrappedLeftExpr->getArgs()) === 1 - && $rightType->isInteger()->yes() - ) { - if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); - } - - $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); - if ($isZero->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - return $funcTypes->unionWith( - $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope, $resultFor)->setRootExpr($expr), - ); - } - - if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); - if ($argType->isString()->yes()) { - $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - - $accessory = new AccessoryNonEmptyStringType(); - if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { - $accessory = new AccessoryNonFalsyStringType(); - } - $valueTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope, $resultFor)->setRootExpr($expr); - - return $funcTypes->unionWith($valueTypes); - } - } - } - - // array_key_first($a) !== null - // array_key_last($a) !== null - // array_find_key($a, $cb) !== null - if ( - $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && isset($unwrappedLeftExpr->getArgs()[0]) - && $rightType->isNull()->yes() - ) { - $funcName = $unwrappedLeftExpr->name->toLowerString(); - $bothDirections = in_array($funcName, ['array_key_first', 'array_key_last'], true); - $notNullOnly = $funcName === 'array_find_key'; - if ($bothDirections || $notNullOnly) { - $args = $unwrappedLeftExpr->getArgs(); - $argType = $getType($args[0]->value); - if ($argType->isArray()->yes()) { - if ($bothDirections) { - return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); - } - if ($context->falsey()) { - return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); - } - } - } - } - - // preg_match($a) === $b - if ( - $context->true() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() - ) { - return ($resultFor($leftExpr) ?? $scope->toMutatingScope()->obtainResultForNode($leftExpr))->getSpecifiedTypesForScope( - $scope->toMutatingScope(), - $context, - )->setRootExpr($expr); - } - - // get_class($a) === 'Foo' - if ( - $context->true() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true) - && isset($unwrappedLeftExpr->getArgs()[0]) - ) { - $constantStringTypes = $rightType->getConstantStrings(); - if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->defaultNarrowingHelper->createForSubject( - $unwrappedLeftExpr->getArgs()[0]->value, - new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), - $context, - $scope, - $resultFor, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); - } - if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->defaultNarrowingHelper->createForSubject( - $unwrappedLeftExpr->getArgs()[0]->value, - $rightType->getClassStringObjectType(), - $context, - $scope, - $resultFor, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); - } - } - - if ( - $context->truthy() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower($unwrappedLeftExpr->name->toString()), [ - 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', - 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', - 'ucwords', 'mb_convert_case', 'mb_convert_kana', - ], true) - && isset($unwrappedLeftExpr->getArgs()[0]) - && $rightType->isNonEmptyString()->yes() - ) { - $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); - - if ($argType->isString()->yes()) { - $specifiedTypes = new SpecifiedTypes(); - if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { - $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( - $unwrappedRightExpr, - TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), - $context, - $scope, - $resultFor, - )->setRootExpr($expr); - } - if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( - $unwrappedRightExpr, - TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), - $context, - $scope, - $resultFor, - )->setRootExpr($expr); - } - - if ($rightType->isNonFalsyString()->yes()) { - return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( - $unwrappedLeftExpr->getArgs()[0]->value, - TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), - $context, - $scope, - $resultFor, - )->setRootExpr($expr)); - } - - return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( - $unwrappedLeftExpr->getArgs()[0]->value, - TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), - $context, - $scope, - $resultFor, - )->setRootExpr($expr)); - } - } - - if ($rightType->isString()->yes()) { - $types = null; - foreach ($rightType->getConstantStrings() as $constantString) { - $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($nodeScopeResolver, $unwrappedLeftExpr, $constantString, $context, $scope, $expr); - - if ($specifiedType === null) { - continue; - } - if ($types === null) { - $types = $specifiedType; - continue; - } - - $types = $types->intersectWith($specifiedType); - } - - if ($types !== null) { - if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)); - } - return $types; - } - } - - $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); - if ($expressions !== null) { - $exprNode = $expressions[0]; - $constantType = $expressions[1]; - - $unwrappedExprNode = $exprNode; - if ($exprNode instanceof AlwaysRememberedExpr) { - $unwrappedExprNode = $exprNode->getExpr(); - } - - $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr, $resultFor); - if ($specifiedType !== null) { - if ($exprNode !== $unwrappedExprNode) { - $specifiedType = $specifiedType->unionWith( - $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($expr), - ); - } - return $specifiedType; - } - } - - // $a::class === 'Foo' - if ( - $context->true() && - $unwrappedLeftExpr instanceof ClassConstFetch && - $unwrappedLeftExpr->class instanceof Expr && - $unwrappedLeftExpr->name instanceof Node\Identifier && - $unwrappedRightExpr instanceof ClassConstFetch && - strtolower($unwrappedLeftExpr->name->toString()) === 'class' - ) { - $constantStrings = $rightType->getConstantStrings(); - if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->defaultNarrowingHelper->createForSubject( - $unwrappedLeftExpr->class, - new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), - $context, - $scope, - $resultFor, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); - } - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new Instanceof_( - $unwrappedLeftExpr->class, - new Name($constantStrings[0]->getValue()), - ), - $context, - )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); - } - } - - $leftType = $getType($leftExpr); - - // 'Foo' === $a::class - if ( - $context->true() && - $unwrappedRightExpr instanceof ClassConstFetch && - $unwrappedRightExpr->class instanceof Expr && - $unwrappedRightExpr->name instanceof Node\Identifier && - $unwrappedLeftExpr instanceof ClassConstFetch && - strtolower($unwrappedRightExpr->name->toString()) === 'class' - ) { - $constantStrings = $leftType->getConstantStrings(); - if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->defaultNarrowingHelper->createForSubject( - $unwrappedRightExpr->class, - new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), - $context, - $scope, - $resultFor, - )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); - } - - return $this->defaultNarrowingHelper->specifyTypesForNode( - $scope, - new Instanceof_( - $unwrappedRightExpr->class, - new Name($constantStrings[0]->getValue()), - ), - $context, - )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); - } - } - - if ($context->false()) { - $identicalType = $getType($expr); - if ($identicalType instanceof ConstantBooleanType) { - $never = new NeverType(); - $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); - return $leftTypes->unionWith($rightTypes); - } - } - - $types = null; - if ( - count($leftType->getFiniteTypes()) === 1 - || ( - $context->true() - && $leftType->isConstantValue()->yes() - && !$rightType->equals($leftType) - && $rightType->isSuperTypeOf($leftType)->yes()) - ) { - $types = $this->defaultNarrowingHelper->createForSubject( - $rightExpr, - $leftType, - $context, - $scope, - $resultFor, - )->setRootExpr($expr); - } - if ( - count($rightType->getFiniteTypes()) === 1 - || ( - $context->true() - && $rightType->isConstantValue()->yes() - && !$leftType->equals($rightType) - && $leftType->isSuperTypeOf($rightType)->yes() - ) - ) { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - if ($types !== null) { - $types = $types->unionWith($leftTypes); - } else { - $types = $leftTypes; - } - } - - if ($types !== null) { - return $types; - } - - $leftExprString = $this->exprPrinter->printExpr($unwrappedLeftExpr); - $rightExprString = $this->exprPrinter->printExpr($unwrappedRightExpr); - if ($leftExprString === $rightExprString) { - if (!$unwrappedLeftExpr instanceof Expr\Variable || !$unwrappedRightExpr instanceof Expr\Variable) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - if ($context->true()) { - $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); - $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); - return $leftTypes->unionWith($rightTypes); - } elseif ($context->false()) { - return $this->defaultNarrowingHelper->createForSubject($leftExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) - ->intersectWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); - } - - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - - /** - * @return array{Expr, ConstantScalarType, Type}|null - */ - private function findTypeExpressionsFromBinaryOperation(NodeScopeResolver $nodeScopeResolver, Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array - { - // the operands were processed during processExpr; read their stored results - // instead of re-walking via Scope::getType(). - $leftType = $nodeScopeResolver->readTypeOfMaybeStored($binaryOperation->left, $scope->toMutatingScope()); - $rightType = $nodeScopeResolver->readTypeOfMaybeStored($binaryOperation->right, $scope->toMutatingScope()); - - $rightExpr = $binaryOperation->right; - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightExpr = $rightExpr->getExpr(); - } - - $leftExpr = $binaryOperation->left; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftExpr = $leftExpr->getExpr(); - } - - if ( - $leftType instanceof ConstantScalarType - && !$rightExpr instanceof ConstFetch - ) { - return [$binaryOperation->right, $leftType, $rightType]; - } elseif ( - $rightType instanceof ConstantScalarType - && !$leftExpr instanceof ConstFetch - ) { - return [$binaryOperation->left, $rightType, $leftType]; - } - - return null; - } - - /** - * @param Closure(Expr): ?ExpressionResult $resultFor - */ - private function specifyTypesForConstantBinaryExpression( - Expr $exprNode, - Type $constantType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - Closure $resultFor, - ): ?SpecifiedTypes - { - if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); - if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { - return $types; - } - - return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( - $scope->toMutatingScope(), - $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), - )->setRootExpr($rootExpr)); - } - - if (!$context->null() && $constantType->isTrue()->yes()) { - $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); - if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { - return $types; - } - - return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( - $scope->toMutatingScope(), - $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), - )->setRootExpr($rootExpr)); - } - - return null; - } - - private function specifyTypesForConstantStringBinaryExpression( - NodeScopeResolver $nodeScopeResolver, - Expr $exprNode, - Type $constantType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes - { - $scalarValues = $constantType->getConstantScalarValues(); - if (count($scalarValues) !== 1 || !is_string($scalarValues[0])) { - return null; - } - $constantStringValue = $scalarValues[0]; - - if ( - $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower($exprNode->name->toString()) === 'gettype' - && isset($exprNode->getArgs()[0]) - ) { - $type = null; - if ($constantStringValue === 'string') { - $type = new StringType(); - } - if ($constantStringValue === 'array') { - $type = new ArrayType(new MixedType(), new MixedType()); - } - if ($constantStringValue === 'boolean') { - $type = new BooleanType(); - } - if (in_array($constantStringValue, ['resource', 'resource (closed)'], true)) { - $type = new ResourceType(); - } - if ($constantStringValue === 'integer') { - $type = new IntegerType(); - } - if ($constantStringValue === 'double') { - $type = new FloatType(); - } - if ($constantStringValue === 'NULL') { - $type = new NullType(); - } - if ($constantStringValue === 'object') { - $type = new ObjectWithoutClassType(); - } - - if ($type !== null) { - $callType = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - $argType = $this->defaultNarrowingHelper->createForSubject($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); - return $callType->unionWith($argType); - } - } - - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower((string) $exprNode->name) === 'get_parent_class' - && isset($exprNode->getArgs()[0]) - ) { - // the argument was processed during processExpr; read its stored result - // instead of re-walking via Scope::getType(). - $argType = $nodeScopeResolver->readTypeOfMaybeStored($exprNode->getArgs()[0]->value, $scope->toMutatingScope()); - $objectType = new ObjectType($constantStringValue); - $classStringType = new GenericClassStringType($objectType); - - if ($argType->isString()->yes()) { - return $this->defaultNarrowingHelper->createForSubject( - $exprNode->getArgs()[0]->value, - $classStringType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ($argType->isObject()->yes()) { - return $this->defaultNarrowingHelper->createForSubject( - $exprNode->getArgs()[0]->value, - $objectType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - return $this->defaultNarrowingHelper->createForSubject( - $exprNode->getArgs()[0]->value, - TypeCombinator::union($objectType, $classStringType), - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ( - $context->false() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && in_array(strtolower((string) $exprNode->name), [ - 'trim', 'ltrim', 'rtrim', 'chop', - 'mb_trim', 'mb_ltrim', 'mb_rtrim', - ], true) - && isset($exprNode->getArgs()[0]) - && $constantStringValue === '' - ) { - $argValue = $exprNode->getArgs()[0]->value; - // the argument was processed during processExpr; read its stored result - // instead of re-walking via Scope::getType(). - $argType = $nodeScopeResolver->readTypeOfMaybeStored($argValue, $scope->toMutatingScope()); - if ($argType->isString()->yes()) { - return $this->defaultNarrowingHelper->createForSubject( - $argValue, - new IntersectionType([ - new StringType(), - new AccessoryNonEmptyStringType(), - ]), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); - } - } - - return null; - } - -} diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 39b1515f28a..98e26e2d5a1 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -53,10 +53,9 @@ * The evaluation scope is a create-time constant of the calling handler (it * carries the flavour and feeds entry composition), never the asking scope. * - * Covers the identity comparisons incrementally; specifyIdentical() returns - * null for a shape it does not handle yet and the caller falls back to the - * old-world EqualityTypeSpecifyingHelper. The fallback dies with the last - * uncovered shape. + * Null from the entry points means the shape carries no specific narrowing + * (unknown-class ::class sides, null-context asks) and the caller applies + * the default truthy/falsey narrowing. */ #[AutowiredService] final class IdenticalNarrowingHelper From 730b5e826fc9049285eb2543e8dc10cad1fa950a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 20:17:30 +0200 Subject: [PATCH 275/378] Key the closure type cache by the closure's actual inputs The closure-type cache keyed on a describe-dump of the entire scope - expensive to build per ask and invalidated by any loop-local churn - and arrow functions were never cached at all, re-walking their body on every ask. The key is now the free-variable slice of the scope (uses and $this for closures, body variables minus parameters for arrows; dynamic access falls back to the whole scope), the caller-fed callable parameter types (array_map types the same node per element), and the type flavour (a no-free-var arrow would otherwise serve its phpdoc return to native asks). Both node kinds share one cache checked before the walk, and every walk path writes through the same key. Roughly 5-8% faster on a NodeScopeResolver.php self-analysis from the cheap keys and the arrow caching. --- .../Helper/ClosureTypeResolver.php | 147 +++++++++++++++--- src/Analyser/MutatingScope.php | 33 +++- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 7d5f59d4e0a..ed357062f65 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -4,6 +4,7 @@ use Generator; use PhpParser\Node; +use PhpParser\NodeFinder; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Expr\Yield_; @@ -41,9 +42,13 @@ use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\VerbosityLevel; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; use function array_key_exists; +use function array_keys; +use function implode; +use function in_array; use function array_map; use function array_merge; use function count; @@ -97,12 +102,28 @@ public function getClosureType( ); } + $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); + if (array_key_exists($cacheKey, $cachedTypes)) { + return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); + } + if (self::$resolveClosureTypeDepth >= 2) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); $arrowFunctionImpurePoints = []; $invalidateExpressions = []; - $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( + self::$resolveClosureTypeDepth++; + try { + $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( new Node\Stmt\Expression($expr->expr), $expr->expr, $arrowScope, @@ -131,7 +152,10 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); }, ExpressionContext::createDeep(), - ); + ); + } finally { + self::$resolveClosureTypeDepth--; + } $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); @@ -139,21 +163,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu // result rather than reading the still-unprocessed body expression $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); - return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); - } - - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey = $scope->getClosureScopeCacheKey(); - if (array_key_exists($cacheKey, $cachedTypes)) { - return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); - } - if (self::$resolveClosureTypeDepth >= 2) { - return new ClosureType( - $parameters, - $scope->getFunctionType($expr->returnType, false, false), - $isVariadic, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey); } self::$resolveClosureTypeDepth++; @@ -221,6 +231,7 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $throwPoints, $impurePoints, $invalidateExpressions, + $cacheKey, ); } @@ -292,11 +303,11 @@ public function buildClosureTypeForArrowFunction( return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); } - [$parameters, $isVariadic] = $this->buildParametersAndAcceptors($scope, $expr); + [$parameters, $isVariadic, $callableParameters] = $this->buildParametersAndAcceptors($scope, $expr); $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); - return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters)); } /** @@ -308,6 +319,95 @@ public function buildClosureTypeForArrowFunction( * them from the closure's passed-to callable type - so the return type read * from the gathered scopes would differ, and getClosureType() must re-walk. */ + /** + * The expression roots this closure's type can read from the enclosing + * scope: '$this' and the use()d variables for closures, '$this' and + * every body variable that is not a parameter for arrow functions. Null + * when the body accesses variables dynamically ($$name, compact(), + * get_defined_vars()) and the whole scope must key the cache. + * + * @return list|null + */ + /** + * The cache key of everything this closure's type can depend on: the + * free-variable slice of the scope plus the parameter types the caller + * feeds in - array_map style callers type the same closure node per + * element through the callable parameters. + * + * @param array<\PHPStan\Reflection\ParameterReflection>|null $callableParameters + * @param array<\PHPStan\Reflection\ParameterReflection> $parameters + */ + private function closureContextCacheKey(MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ?array $callableParameters, array $parameters): string + { + $parts = []; + foreach ($callableParameters ?? $parameters as $parameter) { + $parts[] = $parameter->getType()->describe(VerbosityLevel::cache()); + } + + return $scope->getClosureScopeCacheKey($this->freeVariableRoots($expr)) . '/' . implode('|', $parts) . ($scope->nativeTypesPromoted ? '/native' : '/phpdoc'); + } + + /** + * The expression roots this closure's type can read from the enclosing + * scope - null when the body accesses variables dynamically and the + * whole scope must key the cache. + * + * @return list|null + */ + private function freeVariableRoots(Node\Expr\Closure|ArrowFunction $expr): ?array + { + /** @var list|null|false $cached */ + $cached = $expr->getAttribute('phpstanFreeVariableRoots', false); + if ($cached !== false) { + return $cached; + } + + $roots = []; + if (!$expr->static) { + $roots['$this'] = true; + } + + if ($expr instanceof Node\Expr\Closure) { + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } + $roots['$' . $use->var->name] = true; + } + } else { + $paramNames = []; + foreach ($expr->params as $param) { + if ($param->var instanceof Node\Expr\Variable && is_string($param->var->name)) { + $paramNames['$' . $param->var->name] = true; + } + } + $finder = new NodeFinder(); + foreach ($finder->findInstanceOf([$expr->expr], Node\Expr\Variable::class) as $variable) { + if (!is_string($variable->name)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } + $name = '$' . $variable->name; + if (isset($paramNames[$name])) { + continue; + } + $roots[$name] = true; + } + foreach ($finder->findInstanceOf([$expr->expr], Node\Expr\FuncCall::class) as $call) { + if ($call->name instanceof Node\Name && in_array($call->name->toLowerString(), ['compact', 'extract', 'get_defined_vars'], true)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } + } + } + + $rootList = array_keys($roots); + $expr->setAttribute('phpstanFreeVariableRoots', $rootList); + + return $rootList; + } + private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool { return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null @@ -334,6 +434,7 @@ private function buildClosureTypeFromClosureWalk( array $throwPoints, array $impurePoints, array $invalidateExpressions, + ?string $cacheKey = null, ): ClosureType { $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); @@ -427,7 +528,7 @@ private function buildClosureTypeFromClosureWalk( break; } - return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables); + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables, $cacheKey); } private function resolveArrowFunctionReturnType( @@ -602,7 +703,7 @@ private function buildParametersAndAcceptors( * @param array{returnType: Type, throwPoints: SimpleThrowPoint[], impurePoints: SimpleImpurePoint[], invalidateExpressions: InvalidateExprNode[], usedVariables: string[]} $cachedClosureData */ private function createClosureTypeFromCache( - Node\Expr\Closure $expr, + Node\Expr\Closure|ArrowFunction $expr, array $parameters, bool $isVariadic, array $cachedClosureData, @@ -657,6 +758,7 @@ private function assembleClosureType( array $impurePoints, array $invalidateExpressions, array $usedVariables, + ?string $cacheKey = null, ): ClosureType { foreach ($parameters as $parameter) { @@ -677,7 +779,8 @@ private function assembleClosureType( $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cachedTypes[$scope->getClosureScopeCacheKey()] = [ + $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); + $cachedTypes[$cacheKey] = [ 'returnType' => $returnType, 'throwPoints' => $throwPointsForClosureType, 'impurePoints' => $impurePointsForClosureType, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 6b978ded236..2080ccbfe4a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -117,6 +117,7 @@ use function array_values; use function assert; use function count; +use function ctype_alnum; use function explode; use function implode; use function in_array; @@ -1077,13 +1078,25 @@ public function duplicateWith( ); } - public function getClosureScopeCacheKey(): string + /** + * A cache key of the scope state a closure's type can depend on. With + * $relevantRoots (the closure's free variables, '$this' included) only + * the expression types rooted in them contribute - narrow enough that + * loop-local churn does not invalidate the closure's cached type. Null + * means everything contributes (dynamic variable access in the body). + * + * @param list|null $relevantRoots + */ + public function getClosureScopeCacheKey(?array $relevantRoots = null): string { $parts = []; foreach ($this->expressionTypes as $exprString => $expressionTypeHolder) { if ($expressionTypeHolder->getExpr() instanceof VirtualNode) { continue; } + if ($relevantRoots !== null && !self::exprStringIsRootedIn($exprString, $relevantRoots)) { + continue; + } $parts[] = sprintf('%s::%s', $exprString, $expressionTypeHolder->getType()->describe(VerbosityLevel::cache())); } $parts[] = '---'; @@ -1101,6 +1114,24 @@ public function getClosureScopeCacheKey(): string return md5(implode("\n", $parts)); } + /** @param list $roots */ + private static function exprStringIsRootedIn(string $exprString, array $roots): bool + { + foreach ($roots as $root) { + if ($exprString === $root) { + return true; + } + if (str_starts_with($exprString, $root)) { + $next = $exprString[strlen($root)]; + if ($next !== '_' && !ctype_alnum($next)) { + return true; + } + } + } + + return false; + } + private function resolveType(string $exprString, Expr $node): Type { foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { From 5c04416e2c215bbd39b444096ff4c298c1f4c5e7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 23:24:46 +0200 Subject: [PATCH 276/378] Read assign sentinel base result from the walk storage The scope's storage stack misses results produced during loop-convergence passes (the temporary storage is never pushed onto it), so every holder ask in processAssignVar fell back to a full on-demand re-walk of the assigned expression via the synthetic NotIdentical/Identical sentinels. Reading from the storage the walk just wrote into always hits: worst-case node reprocessing in NodeScopeResolver.php self-analysis drops from a 199x re-walk cluster in processArgs to zero extra walks on that path. --- src/Analyser/ExprHandler/AssignHandler.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 9abfd8172b1..1862ef0d4f5 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -522,7 +522,11 @@ public function processAssignVar( ); } - $storedAssignedExprResult = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($assignedExpr); + // read from the storage the walk just wrote into - the scope's + // storage stack misses it on loop-convergence passes (the temp + // storage is never pushed), which made every holder ask below + // fall back to a full on-demand re-walk of the assigned expression + $storedAssignedExprResult = $storage->findExpressionResult($assignedExpr); $assignedArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($assignedExpr, $storage); $truthyType = TypeCombinator::removeFalsey($type); From 60dc9c367e8e56eac39f3a081eb13c718282c243 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 5 Jul 2026 23:41:02 +0200 Subject: [PATCH 277/378] Compose comparison verdicts from captured results instead of re-walking Three more instances of the convergence storage-stack gap falling back to full on-demand re-walks: - AssignHandler's Variable-branch base type read and the ArrayDimFetch branch's value-to-write reads (both flavours) now read the walk storage first, like the sentinel base does. - BinaryOpHandler's decided-comparison verdict callback computes the comparison's own type from the hoisted typeCallback on storage misses instead of synthesizing an on-demand walk of the whole comparison. - RicherScopeGetTypeHelper::getIdenticalResult/getNotIdenticalResult accept the operand types directly so BinaryOp's typeCallback passes the captured operand results instead of re-reading them from storage. Worst-node reprocessing in NodeScopeResolver.php self-analysis drops from 201x to 139x and the 11+-walk bucket shrinks from 1438 to 1055 nodes. --- src/Analyser/ExprHandler/AssignHandler.php | 28 ++- src/Analyser/ExprHandler/BinaryOpHandler.php | 242 ++++++++++--------- src/Analyser/RicherScopeGetTypeHelper.php | 18 +- 3 files changed, 156 insertions(+), 132 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1862ef0d4f5..2370a17fde3 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -476,7 +476,14 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); + // read from the storage the walk just wrote into - the scope's + // storage stack misses it on loop-convergence passes (the temp + // storage is never pushed), which fell back to a full on-demand + // re-walk of the assigned expression here and on the holder asks below + $storedAssignedExprResult = $storage->findExpressionResult($assignedExpr); + $type = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getTypeOnScope($scopeBeforeAssignEval, $scopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { @@ -522,11 +529,6 @@ public function processAssignVar( ); } - // read from the storage the walk just wrote into - the scope's - // storage stack misses it on loop-convergence passes (the temp - // storage is never pushed), which made every holder ask below - // fall back to a full on-demand re-walk of the assigned expression - $storedAssignedExprResult = $storage->findExpressionResult($assignedExpr); $assignedArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($assignedExpr, $storage); $truthyType = TypeCombinator::removeFalsey($type); @@ -734,8 +736,18 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $valueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); - $nativeValueToWrite = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval->doNotTreatPhpDocTypesAsCertain()); + // read from the storage the walk just wrote into - the scope's storage + // stack misses it on loop-convergence passes (the temp storage is never + // pushed), which fell back to a full on-demand re-walk of the assigned + // expression for both flavours + $storedValueResult = $storage->findExpressionResult($assignedExpr); + $nativeScopeBeforeAssignEval = $scopeBeforeAssignEval->doNotTreatPhpDocTypesAsCertain(); + $valueToWrite = $storedValueResult !== null + ? $storedValueResult->getTypeOnScope($scopeBeforeAssignEval, $scopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $storedValueResult !== null + ? $storedValueResult->getTypeOnScope($nativeScopeBeforeAssignEval, $nativeScopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $nativeScopeBeforeAssignEval); $varType = $varResult->getType(); $varNativeType = $varResult->getNativeType(); diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 7e8630d1207..544c0ad6a42 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -112,148 +112,150 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->left, $storage); $rightArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->right, $storage); - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $leftResult->hasYield() || $rightResult->hasYield(), - isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), - throwPoints: $throwPoints, - impurePoints: $impurePoints, - typeCallback: function (bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { - // the comparison helpers (resolveEqualType / RicherScopeGetTypeHelper) - // read the operand types off the evaluation scope - native-promote it - // here so the native flavour is honoured. - $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; - // the operands were processed during processExpr; read their already - // computed results instead of re-walking via Scope::getType(). - // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are - // priced on demand by the same helper. - $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $nativeTypesPromoted, $beforeScope, $nodeScopeResolver): Type { - if ($e === $expr->left) { - return ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType()); - } - if ($e === $expr->right) { - return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); - } - - return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); - }; - - if ($expr instanceof BinaryOp\Smaller) { - return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { + // the comparison helpers (resolveEqualType / RicherScopeGetTypeHelper) + // read the operand types off the evaluation scope - native-promote it + // here so the native flavour is honoured. + $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // the operands were processed during processExpr; read their already + // computed results instead of re-walking via Scope::getType(). + // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are + // priced on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $nativeTypesPromoted, $beforeScope, $nodeScopeResolver): Type { + if ($e === $expr->left) { + return ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType()); } - - if ($expr instanceof BinaryOp\SmallerOrEqual) { - return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); + if ($e === $expr->right) { + return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); } - if ($expr instanceof BinaryOp\Greater) { - return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); - } + return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); + }; - if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\Smaller) { + return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Equal) { - return $this->resolveEqualType($scope, $expr, $leftResult, $rightResult); - } + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\NotEqual) { - // negation of the Equal result - direct computation avoids - // synthesizing a BooleanNot node (which would route through - // on-demand re-processing once BooleanNot is migrated) - $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right), $leftResult, $rightResult)->toBoolean(); - if ($equalType->isTrue()->yes()) { - return new ConstantBooleanType(false); - } - if ($equalType->isFalse()->yes()) { - return new ConstantBooleanType(true); - } + if ($expr instanceof BinaryOp\Greater) { + return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); + } - return new BooleanType(); - } + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Identical) { - return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr, $nodeScopeResolver)->type; - } + if ($expr instanceof BinaryOp\Equal) { + return $this->resolveEqualType($scope, $expr, $leftResult, $rightResult); + } - if ($expr instanceof BinaryOp\NotIdentical) { - return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr, $nodeScopeResolver)->type; + if ($expr instanceof BinaryOp\NotEqual) { + // negation of the Equal result - direct computation avoids + // synthesizing a BooleanNot node (which would route through + // on-demand re-processing once BooleanNot is migrated) + $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right), $leftResult, $rightResult)->toBoolean(); + if ($equalType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($equalType->isFalse()->yes()) { + return new ConstantBooleanType(true); } - if ($expr instanceof BinaryOp\LogicalXor) { - $leftBooleanType = $getType($expr->left)->toBoolean(); - $rightBooleanType = $getType($expr->right)->toBoolean(); + return new BooleanType(); + } - if ( - $leftBooleanType instanceof ConstantBooleanType - && $rightBooleanType instanceof ConstantBooleanType - ) { - return new ConstantBooleanType( - $leftBooleanType->getValue() xor $rightBooleanType->getValue(), - ); - } + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr, $nodeScopeResolver, $getType($expr->left), $getType($expr->right))->type; + } - return new BooleanType(); - } + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr, $nodeScopeResolver, $getType($expr->left), $getType($expr->right))->type; + } - if ($expr instanceof BinaryOp\Spaceship) { - return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\LogicalXor) { + $leftBooleanType = $getType($expr->left)->toBoolean(); + $rightBooleanType = $getType($expr->right)->toBoolean(); - if ($expr instanceof BinaryOp\Concat) { - return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + if ( + $leftBooleanType instanceof ConstantBooleanType + && $rightBooleanType instanceof ConstantBooleanType + ) { + return new ConstantBooleanType( + $leftBooleanType->getValue() xor $rightBooleanType->getValue(), + ); } - if ($expr instanceof BinaryOp\BitwiseAnd) { - return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); - } + return new BooleanType(); + } - if ($expr instanceof BinaryOp\BitwiseOr) { - return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseXor) { - return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Div) { - return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mod) { - return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Plus) { - return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Minus) { - return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mul) { - return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Pow) { - return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftLeft) { - return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftRight) { - return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); + } - throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); - }, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult): SpecifiedTypes { + if ($expr instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); + } + + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + }; + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $leftResult->hasYield() || $rightResult->hasYield(), + isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: $typeCallback, + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult, $typeCallback): SpecifiedTypes { if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { // `!==` narrowing is the `===` narrowing in the negated context - // no synthetic Identical node. A null context never negates. @@ -274,8 +276,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftArgResult, $rightArgResult, // the comparison's own verdict, in Identical semantics - static function () use ($nodeScopeResolver, $expr, $scope): Type { - $ownType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + static function () use ($nodeScopeResolver, $expr, $scope, $typeCallback): Type { + // on storage misses (loop-convergence passes) compute the + // verdict from the captured operand results instead of + // re-walking the comparison on demand + $ownType = $nodeScopeResolver->findStoredResult($expr, $scope)?->getTypeOnScope($scope, $scope->nativeTypesPromoted); + if ($ownType === null) { + $ownType = $scope->hasExpressionType($expr)->yes() + ? $scope->getTrackedExpressionType($expr) + : $typeCallback($scope->nativeTypesPromoted); + } if ($expr instanceof BinaryOp\NotIdentical) { if ($ownType->isTrue()->yes()) { return new ConstantBooleanType(false); diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index f3e9313b785..699cf35d7f0 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -10,6 +10,7 @@ use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Type; use PHPStan\Type\TypeResult; use function is_string; @@ -27,7 +28,7 @@ public function __construct( /** * @return TypeResult */ - public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeResolver $nodeScopeResolver = null): TypeResult + public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeResolver $nodeScopeResolver = null, ?Type $leftType = null, ?Type $rightType = null): TypeResult { if ( $expr->left instanceof Variable @@ -39,13 +40,14 @@ public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeReso return new TypeResult(new ConstantBooleanType(true), []); } - // $nodeScopeResolver is passed from inside-out callbacks (e.g. BinaryOp's - // typeCallback) so the operands are read from their ExpressionResults - // instead of Scope::getType(); rules call this without it (BC). - $leftType = $nodeScopeResolver !== null + // operand types passed from inside-out callbacks (e.g. BinaryOp's + // typeCallback) come from the already computed ExpressionResults; + // $nodeScopeResolver reads them from storage instead of Scope::getType(); + // rules call this with neither (BC). + $leftType ??= $nodeScopeResolver !== null ? $nodeScopeResolver->readTypeOfMaybeStored($expr->left, $scope->toMutatingScope()) : $scope->getType($expr->left); - $rightType = $nodeScopeResolver !== null + $rightType ??= $nodeScopeResolver !== null ? $nodeScopeResolver->readTypeOfMaybeStored($expr->right, $scope->toMutatingScope()) : $scope->getType($expr->right); @@ -85,9 +87,9 @@ public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeReso /** * @return TypeResult */ - public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, ?NodeScopeResolver $nodeScopeResolver = null): TypeResult + public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, ?NodeScopeResolver $nodeScopeResolver = null, ?Type $leftType = null, ?Type $rightType = null): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right), $nodeScopeResolver); + $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right), $nodeScopeResolver, $leftType, $rightType); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); From 43c5b466ce73a5acc6b7bc6d8d12e99226141bc5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:11:09 +0200 Subject: [PATCH 278/378] Compose nullsafe method call null-removal from the receiver's stored result The typeCallback synthesized a NotIdentical(receiver, null) node and walked it on demand into a throwaway storage just to extract the truthy narrowing, re-walking the receiver subtree the handler had already processed. The narrowing is the identical-narrowing null slice: createSubjectTypes with NullType in the falsey context, composed from the receiver's stored result. --- .../ExprHandler/NullsafeMethodCallHandler.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 5e7647fa5e0..a7847618017 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -91,11 +91,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } + $varResult = $nodeScopeResolver->readStoredResult($expr->var, $storage); + // The `?->`'s own type on the asking scope. $receiverType is the receiver's // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + $nullsafeTypeCallback = function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope, $varResult): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } @@ -103,10 +105,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // the plain method call on the null-removed scope is synthetic; the - // null-removal narrowing is applied to beforeScope (the evaluation point), - // not the asking scope. - $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + // "receiver !== null" composed from the receiver's stored result - + // the identical-narrowing null slice, without synthesizing a + // NotIdentical node and re-walking the receiver on demand; the + // null-removal narrowing is applied to beforeScope (the evaluation + // point), not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varResult, new NullType(), TypeSpecifierContext::createFalsey())); $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); return TypeCombinator::union( From 39b2538c9c2337a693f518752976a36858445330 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:13:57 +0200 Subject: [PATCH 279/378] Compose nullsafe property fetch null-removal from the receiver's stored result Same composition as the nullsafe method call handler: the typeCallback synthesized a NotIdentical(receiver, null) node and walked it on demand into a throwaway storage, re-walking the receiver subtree. Replaced with the identical-narrowing null slice composed from the stored result. --- .../ExprHandler/NullsafePropertyFetchHandler.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 87fbff37eb2..ae61fe94ab7 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -71,11 +71,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $varResult = $nodeScopeResolver->readStoredResult($expr->var, $storage); + // The `?->`'s own type on the asking scope. $receiverType is the receiver's // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + $nullsafeTypeCallback = function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope, $varResult): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } @@ -83,10 +85,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // the plain property fetch on the null-removed scope is synthetic; the - // null-removal narrowing is applied to beforeScope (the evaluation point), - // not the asking scope. - $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + // "receiver !== null" composed from the receiver's stored result - + // the identical-narrowing null slice, without synthesizing a + // NotIdentical node and re-walking the receiver on demand; the + // null-removal narrowing is applied to beforeScope (the evaluation + // point), not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varResult, new NullType(), TypeSpecifierContext::createFalsey())); $propertyFetch = new PropertyFetch($expr->var, $expr->name); return TypeCombinator::union( From 4ff61af1c9b30b7c2021363b9fcdb12c868ae053 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:19:54 +0200 Subject: [PATCH 280/378] Compose variable-variable narrowing from the name expression's result The dynamic-variable typeCallback synthesized an Identical(name, 'str') node per constant string and walked it on demand into a throwaway storage, re-walking the name expression each time. Replaced with specifyIdentical composed from the name expression's captured walk result; the literal side is the result the scalar handler would have produced. createTypeCallback loses its stale static form - AssignHandler no longer uses it. --- src/Analyser/ExprHandler/VariableHandler.php | 45 ++++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 3d5f1a1590d..1738d2c9f76 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -4,7 +4,6 @@ use Closure; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; @@ -14,6 +13,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; @@ -22,6 +22,7 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; @@ -41,6 +42,8 @@ final class VariableHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } @@ -51,16 +54,13 @@ public function supports(Expr $expr): bool } /** - * Evaluates the variable as a read on the asking scope. Also used by - * AssignHandler for the placeholder result it stores for an assignment - * target - every stored result for a Variable node must carry a - * typeCallback so it can resolve its own type from the stored result. + * Evaluates the variable as a read on the asking scope. * * @return Closure(bool $nativeTypesPromoted): Type */ - public static function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, MutatingScope $beforeScope, ?ExpressionResult $nameResult = null): Closure + private function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, MutatingScope $beforeScope, ?ExpressionResult $nameResult = null, ?ExpressionResult $nameArgResult = null): Closure { - return static function (bool $nativeTypesPromoted) use ($expr, $nameResult, $nodeScopeResolver, $beforeScope): Type { + return function (bool $nativeTypesPromoted) use ($expr, $nameResult, $nameArgResult, $nodeScopeResolver, $beforeScope): Type { $readScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if (is_string($expr->name)) { if ($readScope->hasVariableType($expr->name)->no()) { @@ -79,7 +79,34 @@ public static function createTypeCallback(Variable $expr, NodeScopeResolver $nod if (count($nameType->getConstantStrings()) > 0) { $types = []; foreach ($nameType->getConstantStrings() as $constantString) { - $variableScope = $readScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $readScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($readScope, TypeSpecifierContext::createTruthy())); + // "name === 'str'" composed from the name expression's walk + // result - no synthetic Identical walk; the literal side is a + // result the scalar handler would have produced + $literalExpr = new String_($constantString->getValue()); + $literalResult = $this->expressionResultFactory->create( + $readScope, + beforeScope: $readScope, + expr: $literalExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (): Type => $constantString, + specifyTypesCallback: static fn (): SpecifiedTypes => new SpecifiedTypes(), + ); + $specifiedTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->name, + $literalExpr, + $nameResult, + $literalResult, + TypeSpecifierContext::createTruthy(), + $readScope, + $nameArgResult, + null, + fn (): Type => $this->initializerExprTypeResolver->resolveIdenticalType($nameType, $constantString)->type, + ); + $variableScope = $readScope->applySpecifiedTypes($specifiedTypes ?? new SpecifiedTypes()); if ($variableScope->hasVariableType($constantString->getValue())->no()) { $types[] = new ErrorType(); continue; @@ -125,7 +152,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, - typeCallback: self::createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult), + typeCallback: $this->createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult, is_string($expr->name) ? null : $this->identicalNarrowingHelper->captureFirstArgResult($expr->name, $storage)), specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } From 1ec7c65c250586f09a5fd5f37d5c47941c47e722 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:22:33 +0200 Subject: [PATCH 281/378] Read by-ref destructuring item types from the scope directly A plain variable read is scope state - pricing a synthetic Variable node on demand (twice, once per flavour) paid a full walk for a holder read. --- src/Analyser/ExprHandler/AssignHandler.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 2370a17fde3..fa482314f3c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1541,8 +1541,11 @@ private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Mu $refVarName = $arrayItem->value->name; $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $refType = $nodeScopeResolver->processSyntheticOnDemand(new Variable($refVarName), $scope)->getTypeOnScope($scope, false); - $refNativeType = $nodeScopeResolver->processSyntheticOnDemand(new Variable($refVarName), $scope)->getTypeOnScope($scope, true); + // a plain variable read is scope state - no need to price a synthetic + // Variable node on demand (mirrors VariableHandler's typeCallback) + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + $refType = $scope->hasVariableType($refVarName)->no() ? new ErrorType() : $scope->getVariableType($refVarName); + $refNativeType = $nativeScope->hasVariableType($refVarName)->no() ? new ErrorType() : $nativeScope->getVariableType($refVarName); // When $rootVarName's array key changes, update $refVarName $scope = $scope->assignExpression( From fa16a1f960c822a49f23e34a330b97356b086882 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:26:23 +0200 Subject: [PATCH 282/378] Compose match arm condition verdicts and narrowing from walk results Each arm condition synthesized an Identical(subject, cond) node and priced it on demand (twice when PHPDoc types are not certain) to get its verdict and both narrowing contexts. Composed instead from the subject's and the condition's walk results, mirroring BinaryOpHandler's seam: richer getIdenticalResult for the verdict, specifyIdentical with the default fallback for the narrowing. --- src/Analyser/ExprHandler/MatchHandler.php | 32 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 5922a9f7cee..3c5b4e4c071 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -20,9 +20,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; @@ -63,6 +65,8 @@ public function __construct( private bool $treatPhpDocTypesAsCertain, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, ) { $this->capturedArmResults = new WeakMap(); @@ -335,17 +339,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armCondResult->getImpurePoints()); $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); $armCondResultScope = $armCondResult->getScope(); - // the `subject === cond` comparison is synthetic - process it on - // demand once and consume its type and both narrowing contexts. - $armCondSyntheticResult = $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope); + // the `subject === cond` verdict and both narrowing contexts, + // composed from the subject's and the condition's walk results - + // no synthetic Identical walk (mirrors BinaryOpHandler's seam) $armCondType = $this->treatPhpDocTypesAsCertain - ? $armCondSyntheticResult->getTypeOnScope($armCondResultScope, false) - : $nodeScopeResolver->processSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain())->getTypeOnScope($armCondResultScope, true); + ? $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $condResult->getType(), $armCondResult->getType())->type + : $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope->doNotTreatPhpDocTypesAsCertain(), $armCondExpr, $nodeScopeResolver, $condResult->getNativeType(), $armCondResult->getNativeType())->type; if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } - $armCondScope = $armCondResultScope->applySpecifiedTypes($armCondSyntheticResult->getSpecifiedTypesForScope($armCondResultScope, TypeSpecifierContext::createFalsey())); - $armCondTruthyScope = $armCondResultScope->applySpecifiedTypes($armCondSyntheticResult->getSpecifiedTypesForScope($armCondResultScope, TypeSpecifierContext::createTruthy())); + $condArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->cond, $storage); + $armCondArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($armCond, $storage); + $specifyArmCond = fn (TypeSpecifierContext $specifyContext): SpecifiedTypes => ($this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->cond, + $armCond, + $condResult, + $armCondResult, + $specifyContext, + $armCondResultScope, + $condArgResult, + $armCondArgResult, + fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $condResult->getType(), $armCondResult->getType())->type, + ) ?? $this->defaultNarrowingHelper->specifyDefaultTypes($armCondExpr, $specifyContext))->setRootExpr($armCondExpr); + $armCondScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createFalsey())); + $armCondTruthyScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createTruthy())); if ($bodyScope === null) { $bodyScope = $armCondTruthyScope; } else { From 61b15345138c96f6a2bd29c464176c8ca37d0076 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 00:29:18 +0200 Subject: [PATCH 283/378] Use default narrowing directly for bool-variable contradiction probing A walked Variable's specify callback is exactly the default narrowing - pricing a synthetic Variable node on demand per probed variable paid a walk for nothing. The NodeScopeResolver parameter became unused. --- src/Analyser/ExprHandler/MatchHandler.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 3c5b4e4c071..77ac1c1d65a 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -407,7 +407,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { - if ($this->isScopeConditionallyImpossible($nodeScopeResolver, $matchScope)) { + if ($this->isScopeConditionallyImpossible($matchScope)) { $hasAlwaysTrueCond = true; $matchScope = $matchScope->addTypeToExpression($expr->cond, new NeverType()); } @@ -511,7 +511,7 @@ private function getFilteringExprForMatchArm(Match_ $expr, array $conditions): B ); } - private function isScopeConditionallyImpossible(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope): bool + private function isScopeConditionallyImpossible(MutatingScope $scope): bool { $boolVars = []; foreach ($scope->getDefinedVariables() as $varName) { @@ -530,15 +530,16 @@ private function isScopeConditionallyImpossible(NodeScopeResolver $nodeScopeReso // Check if any boolean variable's both truth values lead to contradictions foreach ($boolVars as $varName) { $varExpr = new Variable($varName); - $varExprResult = $nodeScopeResolver->processSyntheticOnDemand($varExpr, $scope); + // a walked Variable's specify callback is exactly the default + // narrowing - no need to price the synthetic node on demand - $truthyScope = $scope->applySpecifiedTypes($varExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); + $truthyScope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->specifyDefaultTypes($varExpr, TypeSpecifierContext::createTruthy())); $truthyContradiction = $this->scopeHasNeverVariable($truthyScope, $boolVars); if (!$truthyContradiction) { continue; } - $falseyScope = $scope->applySpecifiedTypes($varExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); + $falseyScope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->specifyDefaultTypes($varExpr, TypeSpecifierContext::createFalsey())); $falseyContradiction = $this->scopeHasNeverVariable($falseyScope, $boolVars); if ($falseyContradiction) { return true; From 0e9f037c38a4c2bbdc53ca10c7ce016ffacef916 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 08:30:00 +0200 Subject: [PATCH 284/378] Compose single-condition match arm filtering verdicts from walk results The arm-reachability check priced the synthetic filtering expression on demand; for single-condition arms it is the same subject === cond comparison already composable from the walk results via richer getIdenticalResult. The falsey filtering narrowing stays a synthetic walk: it re-prices the subject on the arm-narrowed scope, and that progressive narrowing is what lets the last arm decide exhaustiveness (bug-6064). Multi-condition arms keep composing through in_array so the narrowing stays owned by the in_array type-specifying extension. --- src/Analyser/ExprHandler/MatchHandler.php | 44 +++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 77ac1c1d65a..c0ffe9c5e07 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -324,10 +324,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $filteringExprs = []; + $filteringCondData = []; $armCondScope = $matchScope; $condNodes = []; $armCondResultScope = $matchScope; $bodyScope = null; + $condArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->cond, $storage); foreach ($arm->conds as $j => $armCond) { if (isset($armCondsToSkip[$i][$j])) { continue; @@ -348,7 +350,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } - $condArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->cond, $storage); $armCondArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($armCond, $storage); $specifyArmCond = fn (TypeSpecifierContext $specifyContext): SpecifiedTypes => ($this->identicalNarrowingHelper->specifyIdentical( $nodeScopeResolver, @@ -370,11 +371,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $bodyScope = $bodyScope->mergeWith($armCondTruthyScope); } $filteringExprs[] = $armCond; + $filteringCondData[] = [$armCond, $armCondResult]; } - $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); - $filteringExprResult = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope); - $bodyScope ??= $matchScope->applySpecifiedTypes($filteringExprResult->getSpecifiedTypesForScope($matchScope, TypeSpecifierContext::createTruthy())); + if (count($filteringCondData) === 1) { + // single-condition arm: the filtering expression is the same + // subject === cond comparison - compose its verdict from the + // walk results instead of pricing a synthetic node ($bodyScope + // is always set here, so the multi-cond branch's ??= has no + // single-cond counterpart) + if ($bodyScope === null) { + throw new ShouldNotHappenException(); + } + [$filteringCond, $filteringCondResult] = $filteringCondData[0]; + $filteringIdentical = new BinaryOp\Identical($expr->cond, $filteringCond); + $filteringExprType = $this->richerScopeGetTypeHelper->getIdenticalResult($matchScope, $filteringIdentical, $nodeScopeResolver, $condResult->getType(), $filteringCondResult->getType())->type; + // the falsey narrowing stays a synthetic walk: the walk re-prices + // the subject on the arm-narrowed scope, and that progressive + // narrowing (each arm sees the subject minus the previous arms' + // values) is what lets the last arm decide exhaustiveness - + // composing from the original subject result loses it (bug-6064) + $filteringFalseyTypes = $nodeScopeResolver->processSyntheticOnDemand($filteringIdentical, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()); + } else { + // multi-condition arms compose through in_array so the narrowing + // stays owned by the in_array type-specifying extension; arms + // whose conditions were all skipped keep the empty in_array + // (always false) + $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); + $filteringExprResult = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope); + $bodyScope ??= $matchScope->applySpecifiedTypes($filteringExprResult->getSpecifiedTypesForScope($matchScope, TypeSpecifierContext::createTruthy())); + $filteringExprType = $filteringExprResult->getTypeOnScope($matchScope, false); + $filteringFalseyTypes = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()); + } $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -395,15 +423,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); // Mirror getArmScopesAndTypes: an arm whose filtering expression is // always false is unreachable and does not contribute to the result - // type. The filtering expression is synthetic - price it on demand - // against the current storage instead of re-walking via Scope::getType(). - $filteringExprType = $filteringExprResult->getTypeOnScope($matchScope, false); + // type. if (!$filteringExprType->isFalse()->yes()) { $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; } - $matchScope = $armCondScope->applySpecifiedTypes( - $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()), - ); + $matchScope = $armCondScope->applySpecifiedTypes($filteringFalseyTypes); } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { From 28654962647bf387a34f5cd112fa7e5a0a03f26a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 09:10:37 +0200 Subject: [PATCH 285/378] Reuse the ensured-scope call result for the nullsafe maybe-null type The nullsafe typeCallbacks re-derived the null-removed scope and re-priced a plain call/fetch on it at every ask - but the handler already walks the plain node on the ensured (null-removed) scope during processExpr, so that result is exactly the type being recomputed. The maybe-null branch is now union(exprResult, null), and the last per-ask narrowing derivation in the nullsafe handlers is gone. --- .../ExprHandler/NullsafeMethodCallHandler.php | 17 +++++------------ .../NullsafePropertyFetchHandler.php | 17 +++++------------ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index a7847618017..3886fb3fe17 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -91,13 +91,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } - $varResult = $nodeScopeResolver->readStoredResult($expr->var, $storage); - // The `?->`'s own type on the asking scope. $receiverType is the receiver's // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope, $varResult): Type { + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($exprResult, $receiverType): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } @@ -105,16 +103,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // "receiver !== null" composed from the receiver's stored result - - // the identical-narrowing null slice, without synthesizing a - // NotIdentical node and re-walking the receiver on demand; the - // null-removal narrowing is applied to beforeScope (the evaluation - // point), not the asking scope. - $truthyScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varResult, new NullType(), TypeSpecifierContext::createFalsey())); - $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); - + // the plain call was already priced on the ensured (null-removed) + // scope during processExpr - its result is the call's type on the + // non-null receiver; the short-circuit contributes the null return TypeCombinator::union( - $nodeScopeResolver->processSyntheticOnDemand($methodCall, $truthyScope)->getTypeOnScope($truthyScope, $nativeTypesPromoted), + $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(), new NullType(), ); }; diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index ae61fe94ab7..744edc25e9f 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -71,13 +71,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - $varResult = $nodeScopeResolver->readStoredResult($expr->var, $storage); - // The `?->`'s own type on the asking scope. $receiverType is the receiver's // real type, captured before it was ensured non-null; reading its stored // result here would see the non-null device type and drop the // short-circuit's null. - $nullsafeTypeCallback = function (bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope, $varResult): Type { + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($exprResult, $receiverType): Type { if ($receiverType->isNull()->yes()) { return new NullType(); } @@ -85,16 +83,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); } - // "receiver !== null" composed from the receiver's stored result - - // the identical-narrowing null slice, without synthesizing a - // NotIdentical node and re-walking the receiver on demand; the - // null-removal narrowing is applied to beforeScope (the evaluation - // point), not the asking scope. - $truthyScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varResult, new NullType(), TypeSpecifierContext::createFalsey())); - $propertyFetch = new PropertyFetch($expr->var, $expr->name); - + // the plain fetch was already priced on the ensured (null-removed) + // scope during processExpr - its result is the fetch's type on the + // non-null receiver; the short-circuit contributes the null return TypeCombinator::union( - $nodeScopeResolver->processSyntheticOnDemand($propertyFetch, $truthyScope)->getTypeOnScope($truthyScope, $nativeTypesPromoted), + $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(), new NullType(), ); }; From d4577e3f6e28e9ca3d409ff650b1b76bba1bbdbd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 09:32:40 +0200 Subject: [PATCH 286/378] Extract the coalesce type and falsey-narrowing composition into a helper Pure move: CoalesceCompositionHelper::composeType/getFalseySpecifiedTypes take the sides' walk results explicitly so AssignOpHandler's ??= can reuse the composition instead of synthesizing a Coalesce node and re-walking it per type ask. --- src/Analyser/ExprHandler/CoalesceHandler.php | 73 +++---------- .../Helper/CoalesceCompositionHelper.php | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+), 59 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 3b1a4254293..b5340aaca22 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; @@ -36,6 +37,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -45,24 +47,6 @@ public function supports(Expr $expr): bool return $expr instanceof Coalesce; } - /** - * A falsey coalesce means its left side was null (when it was surely set) - - * shared by the specifyTypesCallback and by processExpr() for the scope - * the right side evaluates under. - * - * @param Coalesce $expr - */ - private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, ExpressionResult $condResult, TypeSpecifierContext $context): SpecifiedTypes - { - $isset = $condResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); - - if ($isset !== true) { - return new SpecifiedTypes(); - } - - return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), $context->negate())->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -74,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the falsey narrowing of this very node - asking the scope about it // mid-processing would take the on-demand path and recurse - $rightScope = $scope->applySpecifiedTypes($this->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); + $rightScope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); // the left-is-set narrowing, composed from the already-processed chain // results - the inside-out equivalent of narrowing by isset($expr->left) @@ -106,54 +90,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - typeCallback: function (bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope, $chainResults): Type { + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->coalesceCompositionHelper->composeType( + $nodeScopeResolver, + $expr->left, + $condResult, + $rightResult, // the isset resolution and the left-is-set narrowing run on // beforeScope (the evaluation point), not the asking scope. - $result = $condResult->getIssetabilityResolution($beforeScope, false)->isSet(static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - return !$isNull->yes(); - }); - - // the left side's type when it is set: the left re-processed on the - // left-is-set narrowed scope (a genuinely different scope than the - // left's own - offsets resolve against the HasOffset-narrowed parent) - $leftIsSetType = function () use ($expr, $nodeScopeResolver, $beforeScope, $chainResults): Type { - $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( - $beforeScope, - $expr->left, - $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $beforeScope, $nodeScopeResolver), - $expr, - TypeSpecifierContext::createTruthy(), - ); - - return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($leftIssetTypes), new ExpressionResultStorage())->getType()); - }; - - if ($result !== null && $result !== false) { - return $leftIsSetType(); - } - - // the right side was processed on the left-is-null scope, so its own - // result is the evaluation point. - $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); - - if ($result === null) { - return TypeCombinator::union($leftIsSetType(), $rightType); - } - - return $rightType; - }, + $beforeScope, + $chainResults, + $expr, + $nativeTypesPromoted, + ), specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } if (!$context->true()) { - return $this->getFalseySpecifiedTypes($s, $expr, $condResult, $context); + return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $expr->left, $condResult, $expr, $context); } if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean())->yes()) { diff --git a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php new file mode 100644 index 00000000000..b90ebc345a6 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php @@ -0,0 +1,103 @@ +getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + + if ($isset !== true) { + return new SpecifiedTypes(); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($s, $leftExpr, $leftResult, new NullType(), $context->negate())->setRootExpr($rootExpr); + } + + /** + * The `??`'s own type: the left side when it is surely set and non-null, + * the right side when it surely is not, their union otherwise. Runs on the + * evaluation scope (where the sides were walked), not the asking scope. + * + * @param array $chainResults + */ + public function composeType( + NodeScopeResolver $nodeScopeResolver, + Expr $leftExpr, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + MutatingScope $evaluationScope, + array $chainResults, + Expr $rootExpr, + bool $nativeTypesPromoted, + ): Type + { + $result = $leftResult->getIssetabilityResolution($evaluationScope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + // the left side's type when it is set: the left re-processed on the + // left-is-set narrowed scope (a genuinely different scope than the + // left's own - offsets resolve against the HasOffset-narrowed parent) + $leftIsSetType = function () use ($leftExpr, $nodeScopeResolver, $evaluationScope, $chainResults, $rootExpr): Type { + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $evaluationScope, + $leftExpr, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope, $nodeScopeResolver), + $rootExpr, + TypeSpecifierContext::createTruthy(), + ); + + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($leftExpr, $evaluationScope->applySpecifiedTypes($leftIssetTypes), new ExpressionResultStorage())->getType()); + }; + + if ($result !== null && $result !== false) { + return $leftIsSetType(); + } + + // the right side was processed on the left-is-null scope, so its own + // result is the evaluation point. + $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + + if ($result === null) { + return TypeCombinator::union($leftIsSetType(), $rightType); + } + + return $rightType; + } + +} From 637f339890b8329ecb17feb93186d01ef6570e04 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 10:01:05 +0200 Subject: [PATCH 287/378] Compose ??= type and narrowing via the coalesce helper instead of synthesizing a Coalesce AssignOp\Coalesce's typeCallback synthesized a whole Coalesce node and walked it on demand per type ask, and its right-side scope narrowing synthesized a NotIdentical(var, null) walk. Both now compose from the sides' walk results through CoalesceCompositionHelper: the left is pre-read once into a throwaway storage (for its isset descriptor - bug-13623), the right side's walk result is threaded to the typeCallback by reference with an on-demand fallback for fiber-suspended early asks. The pre-read and the fallback are the residual floor; the per-ask Coalesce walk, the per-scope NotIdentical walk, and one duplicate var pre-read are gone. The typeCallback deliberately does not capture $storage: it is stored into $storage as this node's result, so capturing it would form a cycle pinning the whole statement's storage graph alive (measured worker OOM). --- src/Analyser/ExprHandler/AssignOpHandler.php | 79 ++++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 5083c3ddf4d..9d0dc741945 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -6,15 +6,13 @@ use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\BinaryOp; -use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; @@ -28,6 +26,7 @@ use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use function array_merge; @@ -48,6 +47,7 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -79,7 +79,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); } - $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): Type { + $varReadResult = null; + $chainResults = []; + $rhsResult = null; + if ($expr instanceof Expr\AssignOp\Coalesce) { + // the ??= left is otherwise processed only as an assignment target, + // which carries no isset descriptor (bug-13623); price it once as a + // read into a throwaway storage and capture the results (not the + // storage) for the callbacks below - keeping the pre-read out of the + // long-lived shared storage, whose result<->storage cycles would + // otherwise accumulate across every ??= in the file + $varStorage = new ExpressionResultStorage(); + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, $varStorage); + $this->defaultNarrowingHelper->captureChainResults($expr->var, $varStorage, $chainResults); + } + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope, $varReadResult, $chainResults, &$rhsResult): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read @@ -87,19 +102,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); if ($expr instanceof Expr\AssignOp\Coalesce) { - // The coalesce is synthetic; price it on demand. The ??= left is stored - // as an assignment target (no isset descriptor), so inject a read result - // of it (with the descriptor) - otherwise the coalesce resolves a - // descriptor-less leaf that reads as definitely-set and drops the `??` - // branch, losing the optional offset natively (bug-13623). - $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); - $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); - $coalesceStorage = ($beforeScope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); - $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); - - $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); - - return $nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); + // the implied `$var ?? $rhs` composed from the pre-read left result + // ($varReadResult is set exactly under this Coalesce branch) and + // the right side's walk result captured by the inner closure below + if ($rhsResult === null) { + // a fiber-suspended rule can ask before processAssignVar ran + // the right side - price it here on the same left-is-null + // scope the walk uses; the walk's own result replaces it once + // the inner closure runs + $rhsScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varReadResult, new NullType(), TypeSpecifierContext::createTruthy())); + $rhsResult = $nodeScopeResolver->processExprOnDemand($expr->expr, $rhsScope, new ExpressionResultStorage()); + } + + return $this->coalesceCompositionHelper->composeType( + $nodeScopeResolver, + $expr->var, + $varReadResult, + $rhsResult, + $beforeScope, + $chainResults, + $expr, + $nativeTypesPromoted, + ); } if ($expr instanceof Expr\AssignOp\Concat) { @@ -185,10 +209,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, $varReadResult, &$rhsResult): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { - $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); + if ($varReadResult === null) { + throw new ShouldNotHappenException(); + } + // "var === null" for the right side's scope - the + // identical-narrowing null slice composed from the pre-read + // result (NotIdentical falsey = Identical truthy), no + // synthetic NotIdentical walk + $scope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $varReadResult, new NullType(), TypeSpecifierContext::createTruthy())); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $context = $context->enterRightSideAssign( @@ -200,6 +231,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { + $rhsResult = $exprResult; $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $originalScope)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), @@ -234,10 +266,11 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } if ($expr instanceof Expr\AssignOp\Coalesce) { - // the ??= left side is processed as an assignment target, not a read, so - // it carries no isset descriptor; read it on demand so NullCoalesceRule - // gets the chain's IssetabilityResolution off the carried result - $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + if ($varReadResult === null) { + throw new ShouldNotHappenException(); + } + // NullCoalesceRule gets the chain's IssetabilityResolution off the + // pre-read result carried by the node $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $varReadResult, 'on left side of ??='), $beforeScope, $storage, $context); } From a0e64b015436e94957ad8c6b080ac8f76abdd0e4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 10:58:55 +0200 Subject: [PATCH 288/378] Skip the synthetic constructor-call pricing when the return type cannot be never resolveReturnType()'s exact-instantiation path priced a synthetic StaticCall to the constructor on demand for every `new X()`, purely to detect a constructor whose own return type resolves to explicit never. The dynamic static-method return type extensions already ran just above, so only the base constructor return type is left to check - and a plain void/object constructor can never be never. Gate the synthetic pricing on the return type being a NeverType or late-resolvable (conditional/template). On NodeScopeResolver.php self-analysis the Scope::getType bridge's on-demand re-pricings drop from 2697 to 1628 (the whole 1072-strong synthetic-StaticCall category disappears). --- src/Analyser/ExprHandler/NewHandler.php | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index f31670254bf..0cf28ac8e39 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -495,12 +495,21 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } - // $methodCall is a synthetic StaticCall the handler built - it is not a - // source node, so Scope::getType() prices it on demand (the constructor's - // own never-returning conditional return type). - $methodResult = $scope->getType($methodCall); - if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { - return $methodResult; + // A constructor makes `new` never-returning only when its own return type + // is (or can resolve to) explicit never; the dynamic static-method return + // type extensions already ran above, so only the base return type is left + // to check. Pricing the synthetic StaticCall on demand for this is + // expensive and pointless for the overwhelmingly common plain + // void/object constructor - skip it unless the return type could be never. + $constructorReturnType = $parametersAcceptor->getReturnType(); + if ($constructorReturnType instanceof NeverType || $constructorReturnType->hasTemplateOrLateResolvableType()) { + // $methodCall is a synthetic StaticCall the handler built - it is not + // a source node, so Scope::getType() prices it on demand (the + // constructor's own never-returning conditional return type). + $methodResult = $scope->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return $methodResult; + } } $objectType = $isStatic ? new StaticType($classReflection) : new ObjectType($resolvedClassName, classReflection: $classReflection); From 3bf3c140448fe8ae35c1fa57754a11d77e0f9b6d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 11:02:52 +0200 Subject: [PATCH 289/378] Answer Scope::getType for a plain variable from scope state, not on demand A variable read is scope state, always available via getVariableType() - but the rule-facing getType bridge (resolveTypeOfNewWorldHandlerNode) only had a storage lookup and an on-demand fallback, so a rule asking the type of a variable node not tracked in the current storage paid a full processExprOnDemand walk for what is a scope lookup. Short-circuit it the same way readTypeOfMaybeStored() and VariableHandler's typeCallback already do; the closure fast-path right below is the same idea for closures. On NodeScopeResolver.php self-analysis this removes the ~615 Variable on-demand re-pricings from the bridge. --- src/Analyser/MutatingScope.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 2080ccbfe4a..248077fd637 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1186,6 +1186,19 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type } } + // a plain variable read is scope state - answer it directly instead of + // processing the node on demand (mirrors readTypeOfMaybeStored() and + // VariableHandler's typeCallback); rules asking Scope::getType($var) for + // a variable node not tracked in the current storage would otherwise pay + // a full on-demand walk for a scope lookup + if ($node instanceof Expr\Variable && is_string($node->name)) { + if ($scope->hasVariableType($node->name)->no()) { + return new ErrorType(); + } + + return $scope->getVariableType($node->name); + } + // A closure/arrow function type is computed directly (as // resolveCallableTypeForScope() also does) - never by processing it on // demand, which would re-enter ClosureHandler::processExpr() endlessly. From 42ddab43351d7641c6c2e94b20db8db1e6dbcf29 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 11:28:28 +0200 Subject: [PATCH 290/378] Prime the storage with argument results before dynamic function extensions A dynamic-return-type extension calls Scope::getType($arg->value) on the call's arguments. When the call's return type is asked lazily - from a rule, a parent expression, or a later statement - the call's own argument storage frame is no longer current, so those getType asks missed and re-walked each argument on demand (measured ~250 on-demand MethodCall re-walks on NodeScopeResolver.php self-analysis, e.g. array_merge/array_map arg reads). Push a transient storage carrying the already-processed argument results, with the current storage as fallback, around the extension dispatch: the arguments are re-exposed (not re-processed) and every non-argument getType is unchanged. Closures/arrow functions are excluded - the bridge computes their type directly on the asking scope, which a processArgs-time stored result would shadow with a stale type. Bridge on-demand re-pricings on NodeScopeResolver.php drop from 1013 to 634. --- src/Analyser/ExprHandler/FuncCallHandler.php | 59 ++++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 073fe543dcf..0a739a0fbd4 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -1008,7 +1008,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $reflectionScope)) { $functionReflection = $this->reflectionProvider->getFunction($functionName, $reflectionScope); - $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection, $argsResult); if ($resolvedType !== null) { return $resolvedType; } @@ -1078,7 +1078,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin return $cloneType; } - $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection, $argsResult); if ($resolvedType !== null) { return $resolvedType; } @@ -1247,17 +1247,54 @@ private function isFuncCallNarrowable(NodeScopeResolver $nodeScopeResolver, Muta return $this->rememberPossiblyImpureFunctionValues || $isPure->yes(); } - private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection): ?Type + private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection, ArgsResult $argsResult): ?Type { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicFunctionReturnTypeExtensions($functionReflection) as $dynamicFunctionReturnTypeExtension) { - $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( - $functionReflection, - $normalizedNode, - $scope, - ); + $extensions = $this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicFunctionReturnTypeExtensions($functionReflection); + + // Prime a transient storage with the already-processed argument results so + // an extension's Scope::getType($arg->value) reads the stored result instead + // of re-walking the argument on demand: when the return type is asked lazily + // (from a rule, a parent expression, a later statement) the call's own + // storage frame is no longer current, so the arguments would otherwise miss. + // The current storage is the fallback, so every non-argument getType is + // unchanged; the arguments are re-exposed, not re-processed. + // + // Closures/arrow functions are deliberately excluded: the bridge computes + // their type directly (getClosureType) on the asking scope, which a stored + // result - captured at processArgs time - would shadow with a stale type. + $current = $scope->getCurrentExpressionResultStorage(); + $primed = $current !== null ? $current->duplicate() : new ExpressionResultStorage(); + $primedAny = false; + foreach ($normalizedNode->getArgs() as $arg) { + if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { + continue; + } + $argResult = $argsResult->getArgResult($arg->value); + if ($argResult === null) { + continue; + } + $primed->storeExpressionResult($arg->value, $argResult); + $primedAny = true; + } - if ($resolvedType !== null) { - return $resolvedType; + if ($primedAny) { + $scope->pushExpressionResultStorage($primed); + } + try { + foreach ($extensions as $dynamicFunctionReturnTypeExtension) { + $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( + $functionReflection, + $normalizedNode, + $scope, + ); + + if ($resolvedType !== null) { + return $resolvedType; + } + } + } finally { + if ($primedAny) { + $scope->popExpressionResultStorage(); } } From 1432bf6468b26ef1a57af6d59a9e681c3e5cf3d3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 11:50:46 +0200 Subject: [PATCH 291/378] Prime the storage for method and static call dynamic-return extensions too Extends the argument priming from function calls to method/static calls: a dynamic method (or static method) return type extension calls Scope::getType($arg->value) on the call's arguments, which miss and re-walk on demand when the return type is asked after the call's own argument storage frame is no longer current. The priming (build a fallback-linked storage carrying the argument results, excluding closures/arrow functions) is now a shared DynamicReturnTypeStoragePrimer service, so FuncCallHandler's inline copy is replaced by it and MethodCallReturnTypeHelper reuses it - ArgsResult is threaded through both handlers' resolveReturnType into the helper. The measured win on PHPStan's own source is small (it barely uses method return type extensions), but this closes the same on-demand re-walk for extension-heavy code and removes the duplicated priming logic. --- src/Analyser/ExprHandler/FuncCallHandler.php | 40 ++----- .../Helper/DynamicReturnTypeStoragePrimer.php | 70 ++++++++++++ .../Helper/MethodCallReturnTypeHelper.php | 108 ++++++++++-------- .../ExprHandler/MethodCallHandler.php | 9 +- .../ExprHandler/StaticCallHandler.php | 9 +- 5 files changed, 150 insertions(+), 86 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 0a739a0fbd4..685d40543e0 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -21,6 +21,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; @@ -104,6 +105,7 @@ public function __construct( private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, private EarlyTerminatingCallHelper $earlyTerminatingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, ) { } @@ -1251,35 +1253,11 @@ private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $no { $extensions = $this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicFunctionReturnTypeExtensions($functionReflection); - // Prime a transient storage with the already-processed argument results so - // an extension's Scope::getType($arg->value) reads the stored result instead - // of re-walking the argument on demand: when the return type is asked lazily - // (from a rule, a parent expression, a later statement) the call's own - // storage frame is no longer current, so the arguments would otherwise miss. - // The current storage is the fallback, so every non-argument getType is - // unchanged; the arguments are re-exposed, not re-processed. - // - // Closures/arrow functions are deliberately excluded: the bridge computes - // their type directly (getClosureType) on the asking scope, which a stored - // result - captured at processArgs time - would shadow with a stale type. - $current = $scope->getCurrentExpressionResultStorage(); - $primed = $current !== null ? $current->duplicate() : new ExpressionResultStorage(); - $primedAny = false; - foreach ($normalizedNode->getArgs() as $arg) { - if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { - continue; - } - $argResult = $argsResult->getArgResult($arg->value); - if ($argResult === null) { - continue; - } - $primed->storeExpressionResult($arg->value, $argResult); - $primedAny = true; - } - - if ($primedAny) { - $scope->pushExpressionResultStorage($primed); - } + // re-expose the already-processed arguments so an extension's + // Scope::getType($arg->value) reads the stored result instead of re-walking + // the argument on demand (the call's argument storage frame is no longer + // current when the return type is asked lazily) + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $normalizedNode->getArgs(), $argsResult); try { foreach ($extensions as $dynamicFunctionReturnTypeExtension) { $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( @@ -1293,9 +1271,7 @@ private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $no } } } finally { - if ($primedAny) { - $scope->popExpressionResultStorage(); - } + $popPrimedStorage(); } return null; diff --git a/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php b/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php new file mode 100644 index 00000000000..278b0152984 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php @@ -0,0 +1,70 @@ +value) reads the stored result instead of re-walking the + * argument on demand. The call's own argument storage frame is no longer current + * when the return type is asked lazily (from a rule, a parent expression, a later + * statement), so those arguments would otherwise miss and be re-priced. + */ +#[AutowiredService] +final class DynamicReturnTypeStoragePrimer +{ + + /** + * Push a transient storage carrying the argument results (current storage as + * fallback, so every non-argument getType is unchanged) and return the matching + * pop - always call it, in a finally. Closures/arrow functions are excluded: + * the bridge computes their type directly on the asking scope (getClosureType), + * which a processArgs-time stored result would shadow with a stale type. + * + * @param Arg[] $args + * @return Closure(): void + */ + public function pushPrimedStorage(MutatingScope $scope, array $args, ?ArgsResult $argsResult): Closure + { + $noop = static function (): void { + }; + if ($argsResult === null) { + return $noop; + } + + $current = $scope->getCurrentExpressionResultStorage(); + $primed = $current !== null ? $current->duplicate() : new ExpressionResultStorage(); + $primedAny = false; + foreach ($args as $arg) { + if ($arg->value instanceof ClosureExpr || $arg->value instanceof ArrowFunction) { + continue; + } + $argResult = $argsResult->getArgResult($arg->value); + if ($argResult === null) { + continue; + } + $primed->storeExpressionResult($arg->value, $argResult); + $primedAny = true; + } + + if (!$primedAny) { + return $noop; + } + + $scope->pushExpressionResultStorage($primed); + + return static function () use ($scope): void { + $scope->popExpressionResultStorage(); + }; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index b34386598ce..c3c4e23880d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -4,6 +4,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\MethodCall; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; @@ -21,6 +22,7 @@ final class MethodCallReturnTypeHelper public function __construct( private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, + private DynamicReturnTypeStoragePrimer $storagePrimer, ) { } @@ -31,6 +33,7 @@ public function methodCallReturnType( string $methodName, MethodCall|Expr\StaticCall $methodCall, ?ParametersAcceptor $preResolvedAcceptor = null, + ?ArgsResult $argsResult = null, ): ?Type { $typeWithMethod = $scope->filterTypeWithMethod($typeWithMethod, $methodName); @@ -54,64 +57,73 @@ public function methodCallReturnType( return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); } - $resolvedTypes = []; - $allClassNames = $typeWithMethod->getObjectClassNames(); - $handledClassNames = []; - foreach ($allClassNames as $className) { - if ($normalizedMethodCall instanceof MethodCall) { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { - if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { - continue; - } + // re-expose the already-processed arguments so an extension's + // Scope::getType($arg->value) reads the stored result instead of re-walking + // the argument on demand (the call's argument storage frame is no longer + // current when the return type is asked lazily) + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $normalizedMethodCall->getArgs(), $argsResult); + try { + $resolvedTypes = []; + $allClassNames = $typeWithMethod->getObjectClassNames(); + $handledClassNames = []; + foreach ($allClassNames as $className) { + if ($normalizedMethodCall instanceof MethodCall) { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { + continue; + } - $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; - } - } else { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { - continue; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } + } else { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { + continue; + } - $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $methodReflection, - $normalizedMethodCall, - $scope, - ); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $methodReflection, + $normalizedMethodCall, + $scope, + ); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; + } } } - } - if (count($resolvedTypes) > 0) { - if (count($allClassNames) !== count($handledClassNames)) { - $remainingType = $typeWithMethod; - foreach ($handledClassNames as $handledClassName) { - $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); - } - if ($remainingType->hasMethod($methodName)->yes()) { - $remainingMethod = $remainingType->getMethod($methodName, $scope); - $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $remainingMethod->getVariants(), - $remainingMethod->getNamedArgumentsVariants(), - ); - $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + if (count($resolvedTypes) > 0) { + if (count($allClassNames) !== count($handledClassNames)) { + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + } } - } - return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); + return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); + } + } finally { + $popPrimedStorage(); } return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 13f8c539a99..c33e5030719 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -190,6 +191,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult, $nameResult, $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, @@ -247,7 +249,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // never re-running processArgs) - asking // Scope::getType() for the MethodCall here would re-enter this handler on // demand, as its final result is not stored yet. - $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); + $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $varResult, $nameResult, $resolvedParametersAcceptor, $argsResult); $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $methodCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -368,7 +370,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * * @param MethodCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, MethodCall $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, MethodCall $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult): Type { // the receiver (scope-dependent) is read from the operand result; the // method reflection and dynamic-return-type extensions run on the @@ -381,7 +383,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin ? TypeCombinator::addNull($type) : $type; - $resolveMethod = function (string $methodName, MethodCall $methodCall) use ($reflectionScope, $nativeTypesPromoted, $calledOnType, $preResolvedAcceptor): Type { + $resolveMethod = function (string $methodName, MethodCall $methodCall) use ($reflectionScope, $nativeTypesPromoted, $calledOnType, $preResolvedAcceptor, $argsResult): Type { if ($nativeTypesPromoted) { $methodReflection = $reflectionScope->getMethodReflection($calledOnType, $methodName); if ($methodReflection === null) { @@ -397,6 +399,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin $methodName, $methodCall, $preResolvedAcceptor, + $argsResult, ) ?? new ErrorType(); }; diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index d4d858f3253..09dc75eae4a 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -12,6 +12,7 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; @@ -267,6 +268,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classResult, $nameResult, $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, ); $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, @@ -315,7 +317,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // never re-running processArgs) - asking // Scope::getType() for the StaticCall here would re-enter this handler on // demand, as its final result is not stored yet. - $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); + $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $classResult, $nameResult, $resolvedParametersAcceptor, $argsResult); $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $staticCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; @@ -417,7 +419,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * * @param StaticCall $expr */ - private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, StaticCall $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, StaticCall $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult): Type { $classType = $classResult !== null ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) @@ -436,7 +438,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin // the method reflection and dynamic-return-type extensions run on the // reflection scope (the lexical context / beforeScope); the class- // expression type is read from the operand result above. - $resolveStaticMethod = function (string $methodName, StaticCall $staticCall) use ($reflectionScope, $nativeTypesPromoted, $classType, $nodeScopeResolver, $expr, $preResolvedAcceptor): Type { + $resolveStaticMethod = function (string $methodName, StaticCall $staticCall) use ($reflectionScope, $nativeTypesPromoted, $classType, $nodeScopeResolver, $expr, $preResolvedAcceptor, $argsResult): Type { if ($nativeTypesPromoted) { if ($expr->class instanceof Name) { $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); @@ -464,6 +466,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin $methodName, $staticCall, $preResolvedAcceptor, + $argsResult, ) ?? new ErrorType(); }; From 1c068fa8cb33a47039796886a02d0dcf18ffce6f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:05:56 +0200 Subject: [PATCH 292/378] Revert "Answer Scope::getType for a plain variable from scope state, not on demand" This reverts commit 28f1a273e0b6f8a488577d4ce98e19e8036cf668. --- src/Analyser/MutatingScope.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 248077fd637..2080ccbfe4a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1186,19 +1186,6 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type } } - // a plain variable read is scope state - answer it directly instead of - // processing the node on demand (mirrors readTypeOfMaybeStored() and - // VariableHandler's typeCallback); rules asking Scope::getType($var) for - // a variable node not tracked in the current storage would otherwise pay - // a full on-demand walk for a scope lookup - if ($node instanceof Expr\Variable && is_string($node->name)) { - if ($scope->hasVariableType($node->name)->no()) { - return new ErrorType(); - } - - return $scope->getVariableType($node->name); - } - // A closure/arrow function type is computed directly (as // resolveCallableTypeForScope() also does) - never by processing it on // demand, which would re-enter ClosureHandler::processExpr() endlessly. From 0061b8faa6e4a032fd417a379527d42f90c70d02 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:05:56 +0200 Subject: [PATCH 293/378] Revert "Skip the synthetic constructor-call pricing when the return type cannot be never" This reverts commit 18f11b3cd8399bad6e7b4ad039a37af2ae4f54a6. --- src/Analyser/ExprHandler/NewHandler.php | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 0cf28ac8e39..f31670254bf 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -495,21 +495,12 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } - // A constructor makes `new` never-returning only when its own return type - // is (or can resolve to) explicit never; the dynamic static-method return - // type extensions already ran above, so only the base return type is left - // to check. Pricing the synthetic StaticCall on demand for this is - // expensive and pointless for the overwhelmingly common plain - // void/object constructor - skip it unless the return type could be never. - $constructorReturnType = $parametersAcceptor->getReturnType(); - if ($constructorReturnType instanceof NeverType || $constructorReturnType->hasTemplateOrLateResolvableType()) { - // $methodCall is a synthetic StaticCall the handler built - it is not - // a source node, so Scope::getType() prices it on demand (the - // constructor's own never-returning conditional return type). - $methodResult = $scope->getType($methodCall); - if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { - return $methodResult; - } + // $methodCall is a synthetic StaticCall the handler built - it is not a + // source node, so Scope::getType() prices it on demand (the constructor's + // own never-returning conditional return type). + $methodResult = $scope->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return $methodResult; } $objectType = $isStatic ? new StaticType($classReflection) : new ObjectType($resolvedClassName, classReflection: $classReflection); From fa67abad7302be5f3585471ecb2dea2a9e2af8ed Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:05:56 +0200 Subject: [PATCH 294/378] Revert "Compose ??= type and narrowing via the coalesce helper instead of synthesizing a Coalesce" This reverts commit 33ce9d105da23b117c2d34facb76d620df856a20. --- src/Analyser/ExprHandler/AssignOpHandler.php | 79 ++++++-------------- 1 file changed, 23 insertions(+), 56 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 9d0dc741945..5083c3ddf4d 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -6,13 +6,15 @@ use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; +use PhpParser\Node\Expr\BinaryOp; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; @@ -26,7 +28,6 @@ use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use function array_merge; @@ -47,7 +48,6 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, - private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -79,22 +79,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); } - $varReadResult = null; - $chainResults = []; - $rhsResult = null; - if ($expr instanceof Expr\AssignOp\Coalesce) { - // the ??= left is otherwise processed only as an assignment target, - // which carries no isset descriptor (bug-13623); price it once as a - // read into a throwaway storage and capture the results (not the - // storage) for the callbacks below - keeping the pre-read out of the - // long-lived shared storage, whose result<->storage cycles would - // otherwise accumulate across every ??= in the file - $varStorage = new ExpressionResultStorage(); - $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, $varStorage); - $this->defaultNarrowingHelper->captureChainResults($expr->var, $varStorage, $chainResults); - } - - $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope, $varReadResult, $chainResults, &$rhsResult): Type { + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the // inner closure below), so their ExpressionResults are stored - read @@ -102,28 +87,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); if ($expr instanceof Expr\AssignOp\Coalesce) { - // the implied `$var ?? $rhs` composed from the pre-read left result - // ($varReadResult is set exactly under this Coalesce branch) and - // the right side's walk result captured by the inner closure below - if ($rhsResult === null) { - // a fiber-suspended rule can ask before processAssignVar ran - // the right side - price it here on the same left-is-null - // scope the walk uses; the walk's own result replaces it once - // the inner closure runs - $rhsScope = $beforeScope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($beforeScope, $expr->var, $varReadResult, new NullType(), TypeSpecifierContext::createTruthy())); - $rhsResult = $nodeScopeResolver->processExprOnDemand($expr->expr, $rhsScope, new ExpressionResultStorage()); - } - - return $this->coalesceCompositionHelper->composeType( - $nodeScopeResolver, - $expr->var, - $varReadResult, - $rhsResult, - $beforeScope, - $chainResults, - $expr, - $nativeTypesPromoted, - ); + // The coalesce is synthetic; price it on demand. The ??= left is stored + // as an assignment target (no isset descriptor), so inject a read result + // of it (with the descriptor) - otherwise the coalesce resolves a + // descriptor-less leaf that reads as definitely-set and drops the `??` + // branch, losing the optional offset natively (bug-13623). + $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + $coalesceStorage = ($beforeScope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); + $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); + + $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); + + return $nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); } if ($expr instanceof Expr\AssignOp\Concat) { @@ -209,17 +185,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, $varReadResult, &$rhsResult): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { - if ($varReadResult === null) { - throw new ShouldNotHappenException(); - } - // "var === null" for the right side's scope - the - // identical-narrowing null slice composed from the pre-read - // result (NotIdentical falsey = Identical truthy), no - // synthetic NotIdentical walk - $scope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $varReadResult, new NullType(), TypeSpecifierContext::createTruthy())); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $context = $context->enterRightSideAssign( @@ -231,7 +200,6 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { - $rhsResult = $exprResult; $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $originalScope)->isNull()->yes(); return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), @@ -266,11 +234,10 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto } if ($expr instanceof Expr\AssignOp\Coalesce) { - if ($varReadResult === null) { - throw new ShouldNotHappenException(); - } - // NullCoalesceRule gets the chain's IssetabilityResolution off the - // pre-read result carried by the node + // the ??= left side is processed as an assignment target, not a read, so + // it carries no isset descriptor; read it on demand so NullCoalesceRule + // gets the chain's IssetabilityResolution off the carried result + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $varReadResult, 'on left side of ??='), $beforeScope, $storage, $context); } From 16233dd4da67aa817c27735f079f5b0f96e38262 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:23:13 +0200 Subject: [PATCH 295/378] Read narrowing subjects' current types from scope state, not on demand The conditional-holder machinery (processBooleanConditionalTypes, the dropped-self-condition complement) and SpecifiedTypes::normalize() need the current type of each narrowing subject on the scope where the narrowing is being applied. They read it via readTypeOfMaybeStored(), whose stored-result lookup misses whenever the ask arrives on a different storage frame (lazy narrowing asks from getTruthyScope/getFalseyScope of later statements) - falling back to a full on-demand re-walk of a real node, several times per disjunction ask. The subjects are narrowable expressions (variables, property fetches, array offsets - pre-filtered by isTrackableExpression), whose scope-view type is derivable from tracked state without any walk: exactly what resolveScopeStateType() does, and exactly what the old-world resolveType() answered here. Expose it as MutatingScope::getStateType() (flavour follows the scope) and use it at the four sites. Per-Expr census on NodeScopeResolver.php: walks 55048 -> 53620, the 91x/70x/67x reprocessing clusters collapse. Also fixes bug-13303 (match exhaustiveness), a known-red - the scope-view type is the correct semantics where the stored walk-position type was stale. --- .../Helper/ConditionalExpressionHolderHelper.php | 6 +++--- src/Analyser/MutatingScope.php | 12 ++++++++++++ src/Analyser/SpecifiedTypes.php | 7 ++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 8e843e033ce..4f51c2d2f1e 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -153,7 +153,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol continue; } - $scopeType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + $scopeType = $scope->getStateType($expr); $conditionType = TypeCombinator::remove($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -171,7 +171,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol continue; } - $scopeType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + $scopeType = $scope->getStateType($expr); $conditionType = TypeCombinator::intersect($scopeType, $type); if ($scopeType->equals($conditionType)) { $droppedNoOpConditions[$exprString] = true; @@ -229,7 +229,7 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol } $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $nodeScopeResolver->readTypeOfMaybeStored($expr, $targetScope); + $targetType = $targetScope->getStateType($expr); $holderType = $holdersFromSureTypes ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 2080ccbfe4a..3a9bfea8e22 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3052,6 +3052,18 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + /** + * A narrowable expression's current type as this scope sees it, derived + * from tracked state (recursing into operands via reflection/offset reads) + * - never by processing the node. The flavour follows the scope: a + * native-promoted scope answers native types. Non-narrowable expressions + * (calls, constants) fall back to getType(). + */ + public function getStateType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, $this->nativeTypesPromoted); + } + private function getScopeStateType(Expr $expr): Type { return $this->resolveScopeStateType($expr, false); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index a0968f6260f..20ef5dfa1bc 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -224,10 +224,11 @@ public function normalize(Scope $scope, ?NodeScopeResolver $nodeScopeResolver = foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { if (!isset($sureTypes[$exprString])) { - // $nodeScopeResolver is passed from inside-out callbacks so the expr - // type is read from its ExpressionResult instead of Scope::getType(). + // $nodeScopeResolver marks the inside-out (engine) path: the subject's + // current type is derived from the scope's tracked state without + // processing the node. Extensions calling without it read normally. $exprType = $nodeScopeResolver !== null - ? $nodeScopeResolver->readTypeOfMaybeStored($exprNode, $scope->toMutatingScope()) + ? $scope->toMutatingScope()->getStateType($exprNode) : $scope->getType($exprNode); $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($exprType, $sureNotType)]; continue; From 66485cecf2b221e1a399015d083a92bf0e80ef30 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:32:48 +0200 Subject: [PATCH 296/378] Derive non-nullability ensure types from scope state, not on demand ensureShallowNonNullability() reads the chain link's current and original types before the node is processed; readTypeOfMaybeStored() has nothing stored yet at that point, so isset/empty/coalesce/nullsafe chains paid an on-demand re-walk per link per ensure. The links are narrowable expressions whose scope-view type getStateType() derives from tracked state - the same answer the old-world Scope::getType() computed here. The NodeScopeResolver parameter becomes unused and is dropped from both ensure entry points. --- src/Analyser/ExprHandler/CoalesceHandler.php | 2 +- src/Analyser/ExprHandler/EmptyHandler.php | 2 +- .../Helper/NonNullabilityHelper.php | 25 +++++++++---------- src/Analyser/ExprHandler/IssetHandler.php | 2 +- .../ExprHandler/NullsafeMethodCallHandler.php | 2 +- .../NullsafePropertyFetchHandler.php | 2 +- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index b5340aaca22..562b8e62dc6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -50,7 +50,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->left); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 5b9419cf8a1..8dd13577071 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -47,7 +47,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->expr); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index d5d5936815a..f56dffac680 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,7 +11,6 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; use PHPStan\Type\TypeCombinator; @@ -20,12 +19,12 @@ final class NonNullabilityHelper { - public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + public function ensureShallowNonNullability(MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult { // the expression has not been processed into the storage yet (this runs - // before processExprNode), so read its type from the stored result or - // price it on demand instead of re-walking via Scope::getType(). - $exprType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $scope); + // before processExprNode) - derive its current type from the scope's + // tracked state instead of pricing the node on demand. + $exprType = $scope->getStateType($exprToSpecify); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -35,9 +34,9 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $originalScope); + $originalExprType = $originalScope->getStateType($exprToSpecify); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $originalScope->doNotTreatPhpDocTypesAsCertain()); + $originalNativeType = $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -55,8 +54,8 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $nodeScopeResolver->readTypeOfMaybeStored($parentExpr, $scope), - $nodeScopeResolver->readTypeOfMaybeStored($parentExpr, $scope->doNotTreatPhpDocTypesAsCertain()), + $scope->getStateType($parentExpr), + $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr), $originalScope->hasExpressionType($parentExpr), ); } @@ -67,7 +66,7 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver $certainty = $hasExpressionType; } - $nativeType = $nodeScopeResolver->readTypeOfMaybeStored($exprToSpecify, $scope->doNotTreatPhpDocTypesAsCertain()); + $nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -82,12 +81,12 @@ public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver ); } - public function ensureNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult { $specifiedExpressions = []; $originalScope = $scope; - $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope, $nodeScopeResolver) { - $result = $this->ensureShallowNonNullability($nodeScopeResolver, $scope, $originalScope, $expr); + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { + $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index f844f161fe5..8fc0959b5a0 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -85,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = false; $varResults = []; foreach ($expr->vars as $var) { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); $varResults[] = $varResult; diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 3886fb3fe17..5da936108c6 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -62,7 +62,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // carry the receiver type to NullsafeMethodCallRule so it reads it from here // instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $methodCall = new MethodCall( diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 744edc25e9f..40aa5ef43d3 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -60,7 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // carry the receiver type to NullsafePropertyFetchRule so it reads it from // here instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($nodeScopeResolver, $scope, $scope, $expr->var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $propertyFetch = new PropertyFetch( From 500be3a4f0558771ec8953934528ed5418011a78 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 13:36:51 +0200 Subject: [PATCH 297/378] Derive narrowing-application base types from scope state, not on demand applySpecifiedTypes() prices the current [phpdoc, native] pair of every untracked narrowing subject to intersect with or subtract from; the storage misses whenever the narrowing is applied on a different frame than the walk that produced the subject, re-walking real property fetches and array offsets per application. Narrowable shapes read their scope-view types from tracked state; only genuinely synthetic subjects (the plain-chain variant a nullsafe narrowing emits) keep the on-demand pricing. Per-Expr census on NodeScopeResolver.php: walks 53519 -> 52472. --- src/Analyser/MutatingScope.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3a9bfea8e22..5f88b576517 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1238,6 +1238,22 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array $result = $storage->findExpressionResult($expr); if ($result === null) { + // a narrowable expression's scope-view type is derived from tracked + // state - no need to price the node on demand (the storage misses + // whenever the narrowing is applied on a different frame than the + // walk that produced the subject) + if ( + ($expr instanceof Expr\Variable && is_string($expr->name)) + || $expr instanceof PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch + ) { + return [ + $this->resolveScopeStateType($expr, $this->nativeTypesPromoted), + $this->resolveScopeStateType($expr, true), + ]; + } + // a synthetic node - price it on demand, see // resolveTypeOfNewWorldHandlerNode() $scope = $this->toMutatingScope(); From 46c3762e4799c6f2a8131988269118fafee1821a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 14:01:28 +0200 Subject: [PATCH 298/378] Keep void in typeCallbacks; project void to null at the value-read boundary Call handlers no longer transform a void return type to null in their typeCallbacks - the raw own type keeps void, and ExpressionResult projects void->null once, memoized, in resolveOwnType(), which every value read (getType/getNativeType/getTypeOnScope) goes through. The new ExpressionResult::getKeepVoidType() reads the raw type, so Scope::getKeepVoidType() answers from the stored result (or an on-demand walk for out-of-frame nodes like arrow bodies) instead of re-processing a clone of the node with a keep-void attribute. Deletes VoidToNullTypeTransformer, the KEEP_VOID attribute, and its getNodeKey special-casing; the match arms union their raw keep-void types. Behavioral change (approved): void used as a value now reads as null everywhere - assertType('void', $f()) becomes 'null', "expects int, void given" becomes "null given", @var-over-void-assignment messages say native type null. The void-misuse reports are unaffected and still fire ("Result of ... (void) is used." for assignments, arguments, and void match arms); YieldTypeRule/YieldFromTypeRule now read getKeepVoidType() (the gate admits yields) so "Result of yield (void) is used." keeps firing. Test expectations updated accordingly. --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 +- .../Helper/MethodCallReturnTypeHelper.php | 6 +-- .../Helper/VoidToNullTypeTransformer.php | 23 -------- src/Analyser/ExprHandler/MatchHandler.php | 21 +++----- src/Analyser/ExpressionResult.php | 54 ++++++++++++++++++- src/Analyser/MutatingScope.php | 21 ++++++-- src/Analyser/ScopeOps.php | 4 -- src/Rules/Generators/YieldFromTypeRule.php | 2 +- src/Rules/Generators/YieldTypeRule.php | 2 +- .../methodPhpDocs-recursive-trait-defined.php | 2 +- .../data/methodPhpDocs-trait-defined.php | 2 +- .../Analyser/nsrt/closure-return-type.php | 4 +- .../nsrt/functionPhpDocs-phanPrefix.php | 2 +- .../nsrt/functionPhpDocs-phpstanPrefix.php | 2 +- .../nsrt/functionPhpDocs-psalmPrefix.php | 2 +- .../PHPStan/Analyser/nsrt/functionPhpDocs.php | 2 +- ...hpDocs-inheritdoc-without-curly-braces.php | 2 +- .../nsrt/method-phpDocs-inheritdoc.php | 2 +- .../methodPhpDocs-implicitInheritance.php | 2 +- .../nsrt/methodPhpDocs-phanPrefix.php | 2 +- .../nsrt/methodPhpDocs-phpstanPrefix.php | 2 +- .../nsrt/methodPhpDocs-psalmPrefix.php | 2 +- ...missing-closure-native-return-typehint.php | 4 +- .../PHPStan/Analyser/nsrt/mixed-typehint.php | 2 +- tests/PHPStan/Reflection/data/mixedType.php | 2 +- .../Rules/Functions/CallCallablesRuleTest.php | 2 +- .../WrongVariableNameInVarTagRuleTest.php | 14 ++--- 27 files changed, 110 insertions(+), 80 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 685d40543e0..168d9914d51 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -24,7 +24,6 @@ use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; -use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -1086,7 +1085,9 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin } } - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); + // the typeCallback keeps void; ExpressionResult projects void->null for + // value reads, getKeepVoidType() keeps it + return $parametersAcceptor->getReturnType(); } /** diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index c3c4e23880d..dcd72873d6c 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -54,7 +54,7 @@ public function methodCallReturnType( $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); } if ($normalizedMethodCall === null) { - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); + return $parametersAcceptor->getReturnType(); } // re-expose the already-processed arguments so an extension's @@ -120,13 +120,13 @@ public function methodCallReturnType( } } - return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); + return TypeCombinator::union(...$resolvedTypes); } } finally { $popPrimedStorage(); } - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); + return $parametersAcceptor->getReturnType(); } } diff --git a/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php b/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php deleted file mode 100644 index c718f5e8209..00000000000 --- a/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php +++ /dev/null @@ -1,23 +0,0 @@ -getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true) { - return $type; - } - - return TypeTraverser::map($type, new VoidToNullTraverser()); - } - -} diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index c0ffe9c5e07..4446ca16b9d 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -488,21 +488,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // is narrowed to that arm's condition - those captured scopes are the // evaluation points, so the result type is just the union of the arm // body types, no re-walk of the arms needed. - typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $armTypeResults): Type { - $keepVoid = $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true; + typeCallback: static function (bool $nativeTypesPromoted) use ($armTypeResults): Type { + // the union keeps void in the arm bodies (the raw type); + // ExpressionResult projects void->null for value reads and + // getKeepVoidType() keeps it, so UsageOfVoidMatchExpressionRule + // still sees a void arm $types = []; - foreach ($armTypeResults as [$armResult, $bodyScope, $armBody]) { - if ($nativeTypesPromoted) { - $bodyScope = $bodyScope->doNotTreatPhpDocTypesAsCertain(); - } - if ($keepVoid) { - // The only caller is getKeepVoidType (via a synthetic - // clone of the match) - it keeps void in the arm bodies - // instead of transforming it to null. - $types[] = $bodyScope->getKeepVoidType($armBody); - } else { - $types[] = ($nativeTypesPromoted ? $armResult->getNativeType() : $armResult->getType()); - } + foreach ($armTypeResults as [$armResult]) { + $types[] = $armResult->getKeepVoidType($nativeTypesPromoted); } return TypeCombinator::union(...$types); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 1f95c69379e..439b436c171 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -6,8 +6,11 @@ use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\ShouldNotHappenException; +use PHPStan\Analyser\Traverser\VoidToNullTraverser; use PHPStan\Type\Type; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; +use PHPStan\Type\UnionType; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult @@ -34,6 +37,10 @@ final class ExpressionResult private ?Type $resolvedNativeType = null; + private ?Type $projectedType = null; + + private ?Type $projectedNativeType = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints @@ -231,11 +238,17 @@ public function getNativeType(): Type } /** - * The result's own type - the eager value or the memoized typeCallback, + * The result's own raw type - the eager value or the memoized typeCallback, * with no tracked-holder interference. The callback is a pure function of * the flavour flag, so one memo slot per flavour is exact. + * + * A void-returning call keeps `void` here; the void->null projection every + * value read applies happens in resolveOwnType(). getKeepVoidType() reads + * this raw type so a void call used as a value (assigned, passed as an + * argument, a void match arm) is still seen as void by the rules that + * flag that misuse. */ - private function resolveOwnType(bool $nativeTypesPromoted): Type + private function resolveOwnRawType(bool $nativeTypesPromoted): Type { if ($nativeTypesPromoted) { if ($this->nativeType !== null) { @@ -258,6 +271,43 @@ private function resolveOwnType(bool $nativeTypesPromoted): Type return $this->resolvedType ??= TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); } + /** + * The result's own type as a value: the raw type with `void` projected to + * `null` (a void expression evaluates to null). The projection used to live + * in the call handlers' return-type resolution; keeping it at this single + * read boundary lets one raw type serve both value reads and + * getKeepVoidType(). + */ + private function resolveOwnType(bool $nativeTypesPromoted): Type + { + if ($nativeTypesPromoted) { + return $this->projectedNativeType ??= $this->projectVoidToNull($this->resolveOwnRawType(true)); + } + + return $this->projectedType ??= $this->projectVoidToNull($this->resolveOwnRawType(false)); + } + + private function projectVoidToNull(Type $type): Type + { + // void only ever originates from a call return type; the overwhelmingly + // common non-void, non-union result skips the traverser entirely + if ($type->isVoid()->no() && !$type instanceof UnionType) { + return $type; + } + + return TypeTraverser::map($type, new VoidToNullTraverser()); + } + + /** + * The own type with `void` kept (not projected to null) - answers + * Scope::getKeepVoidType() from the stored result instead of re-processing + * the node with a keep-void marker. + */ + public function getKeepVoidType(bool $nativeTypesPromoted): Type + { + return $this->resolveOwnRawType($nativeTypesPromoted); + } + /** * A narrowed or ensured type tracked for the whole expression (e.g. the * nullsafe handlers ensure `($x ?? null)` is not null before processing diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 5f88b576517..be5da0ccbd1 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -139,7 +139,6 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter { - public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; /** @@ -1375,6 +1374,8 @@ public function getKeepVoidType(Expr $node): Type { if ( !$node instanceof Match_ + && !$node instanceof Expr\Yield_ + && !$node instanceof Expr\YieldFrom && ( ( !$node instanceof FuncCall @@ -1392,10 +1393,22 @@ public function getKeepVoidType(Expr $node): Type return $originalType; } - $clonedNode = clone $node; - $clonedNode->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, true); + // the null may be a projected void: read the call's/match's raw + // (void-kept) own type. A result already stored in the current frame is + // read directly; a node evaluated on a different scope - e.g. an arrow + // body typed on the closure scope - is processed on demand there, its + // raw own type keeping void without any keep-void marker on the node. + $storage = $this->expressionResultStorageStack->getCurrent(); + $result = $storage?->findExpressionResult($node); + if ($result === null) { + $result = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $this->toMutatingScope(), + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); + } - return $this->getType($clonedNode); + return $result->getKeepVoidType($this->nativeTypesPromoted); } public function doNotTreatPhpDocTypesAsCertain(): self diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 6b6fd25d48a..30145754daf 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -71,10 +71,6 @@ public static function nodeKey(Expr $node, ExprPrinter $exprPrinter): string $key .= '*/'; } - if (($attributes[MutatingScope::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) { - $key .= '/*' . MutatingScope::KEEP_VOID_ATTRIBUTE_NAME . '*/'; - } - return $key; } diff --git a/src/Rules/Generators/YieldFromTypeRule.php b/src/Rules/Generators/YieldFromTypeRule.php index b77f04b53a4..a0102c5c9e1 100644 --- a/src/Rules/Generators/YieldFromTypeRule.php +++ b/src/Rules/Generators/YieldFromTypeRule.php @@ -137,7 +137,7 @@ public function processNode(Node $node, Scope $scope): array ))->identifier('generator.sendType')->build(); } - if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + if (!$scope->isInFirstLevelStatement() && $scope->getKeepVoidType($node)->isVoid()->yes()) { $messages[] = RuleErrorBuilder::message('Result of yield from (void) is used.') ->identifier('generator.void') ->build(); diff --git a/src/Rules/Generators/YieldTypeRule.php b/src/Rules/Generators/YieldTypeRule.php index e5f94c81932..0f42eecd120 100644 --- a/src/Rules/Generators/YieldTypeRule.php +++ b/src/Rules/Generators/YieldTypeRule.php @@ -86,7 +86,7 @@ public function processNode(Node $node, Scope $scope): array ->identifier('generator.valueType') ->build(); } - if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + if (!$scope->isInFirstLevelStatement() && $scope->getKeepVoidType($node)->isVoid()->yes()) { $messages[] = RuleErrorBuilder::message('Result of yield (void) is used.') ->identifier('generator.void') ->build(); diff --git a/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php b/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php index 70c6ab079a6..e18357cc9cc 100644 --- a/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php +++ b/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php @@ -119,7 +119,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php b/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php index bb7b5c21a85..635d1097ab8 100644 --- a/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php +++ b/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php @@ -119,7 +119,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type.php b/tests/PHPStan/Analyser/nsrt/closure-return-type.php index 386fec990cc..df98e0b3993 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type.php @@ -12,12 +12,12 @@ public function doFoo(int $i): void $f = function () { }; - assertType('void', $f()); + assertType('null', $f()); $f = function () { return; }; - assertType('void', $f()); + assertType('null', $f()); $f = function () { return 1; diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php index 6ded9fd90dd..dd768b725d7 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php @@ -103,7 +103,7 @@ function doFooPhanPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php index d3d7cae930f..ea3c056992e 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php @@ -103,7 +103,7 @@ function doFooPhpstanPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php index 6f78457db28..8e23cd5a948 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php @@ -103,7 +103,7 @@ function doFooPsalmPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php index f38965983a4..96441e6873c 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php @@ -104,7 +104,7 @@ function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php index 2b482e70612..6f930012aff 100644 --- a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php +++ b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php @@ -85,7 +85,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php index 33a0558ceaa..1656c1e0982 100644 --- a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php +++ b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php @@ -85,7 +85,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php index e832ea66913..2f449bf3218 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php @@ -82,7 +82,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php index 58de957d21b..018a22164b3 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php index e7d824a5bbc..baff47d04a2 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php index c81f079f58d..c6a12e197c5 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php b/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php index d516f89f230..3e7ec048530 100644 --- a/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php +++ b/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php @@ -7,10 +7,10 @@ class Foo public function doFoo() { - \PHPStan\Testing\assertType('void', (function () { + \PHPStan\Testing\assertType('null', (function () { })()); - \PHPStan\Testing\assertType('void', (function () { + \PHPStan\Testing\assertType('null', (function () { return; })()); \PHPStan\Testing\assertType('Generator', (function (bool $bool) { diff --git a/tests/PHPStan/Analyser/nsrt/mixed-typehint.php b/tests/PHPStan/Analyser/nsrt/mixed-typehint.php index 5b3c17cbb1a..c7ec23d85ae 100644 --- a/tests/PHPStan/Analyser/nsrt/mixed-typehint.php +++ b/tests/PHPStan/Analyser/nsrt/mixed-typehint.php @@ -30,7 +30,7 @@ function (mixed $foo) { $f = function (): mixed { }; - assertType('void', $f()); + assertType('null', $f()); $f = function () use ($foo): mixed { return $foo; diff --git a/tests/PHPStan/Reflection/data/mixedType.php b/tests/PHPStan/Reflection/data/mixedType.php index d39d5a02aa1..037c65725c3 100644 --- a/tests/PHPStan/Reflection/data/mixedType.php +++ b/tests/PHPStan/Reflection/data/mixedType.php @@ -38,5 +38,5 @@ function (): void { assertType('mixed', $foo); }; - assertType('void', $f(1)); + assertType('null', $f(1)); }; diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 03448315326..b9334972896 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -346,7 +346,7 @@ public function testPipeOperator(): void 24, ], [ - 'Parameter #1 $i of callable \'CallCallablePipe…\' expects int, void given.', + 'Parameter #1 $i of callable \'CallCallablePipe…\' expects int, null given.', 26, ], [ diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 6b63dd6a790..bd0e2b3463a 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -38,11 +38,11 @@ public function testRule(): void { $this->analyse([__DIR__ . '/data/wrong-variable-name-var.php'], [ [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 11, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 14, ], [ @@ -86,7 +86,7 @@ public function testRule(): void 109, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 120, ], [ @@ -552,19 +552,19 @@ public function testAssignOperator(): void { $this->analyse([__DIR__ . '/data/wrong-variable-name-var-assign-op.php'], [ [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 11, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 14, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 20, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 23, ], ]); From 9004eb77cfda5273a776aa1599b3143988bd4baf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 14:16:43 +0200 Subject: [PATCH 299/378] Push the walk's storage onto the scope-visible stack in processStmtNodesInternal Loop-convergence passes thread a throwaway duplicate storage into processStmtNodesInternal that never reached the scope's storage stack, so every in-pass ask - applySpecifiedTypes pricing untracked narrowing subjects, rules asking Scope::getType - missed the pass's own results and re-processed real nodes on demand, several times per convergence iteration. Push the storage when it is not already the current frame (the normal path, where processStmtNodes pushed it, skips this), pop in a finally. This is the storage-stack-gap root fix: per-Expr census on NodeScopeResolver.php drops from 52362 to 36990 walks (avg 2.95 -> 2.14 per node), and the 11+-walks bucket collapses from 852 nodes to 91. Wall/user time and memory of self-analysis are unchanged. The myers-diff loop-widening fixpoint becomes tighter (int<0, max> instead of the benevolent (float|int)) because convergence passes now read their own iteration's results; the guarded regression (benevolent-to-strict downgrade causing return-type false positives) cannot recur with the more precise type - expectation updated. --- src/Analyser/NodeScopeResolver.php | 31 ++++++++++++++----- .../nsrt/myers-diff-loop-widening.php | 4 +-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3913cb079e3..93d0749a35d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -595,14 +595,29 @@ private function processStmtNodesInternal( StatementContext $context, ): InternalStatementResult { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - ); + // make the storage this walk writes into scope-visible: loop-convergence + // passes thread a throwaway duplicate that would otherwise never reach + // the storage stack, so every in-pass ask (applySpecifiedTypes pricing, + // rules via Scope::getType) would miss the pass's own results and + // re-process real nodes on demand + $pushStorage = $scope->getCurrentExpressionResultStorage() !== $storage; + if ($pushStorage) { + $scope->pushExpressionResultStorage($storage); + } + try { + $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( + $parentNode, + $stmts, + $scope, + $storage, + $nodeCallback, + $context, + ); + } finally { + if ($pushStorage) { + $scope->popExpressionResultStorage(); + } + } // Flush pending fibers only at a scope boundary - a function/method body, // a class/trait body, a namespace. Nested control-flow statement lists // (if/else branches, loop and switch/try bodies) must NOT flush: a rule diff --git a/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php b/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php index a0a3c90786c..c05c1e46f06 100644 --- a/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php +++ b/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php @@ -44,8 +44,8 @@ public function calculateTrace(array $old, array $new): array $y++; } - assertType('(float|int)', $x); - assertType('(float|int)', $y); + assertType('int<0, max>', $x); + assertType('int<0, max>', $y); $v[$k] = $x; if ($x >= $n && $y >= $m) { From 95d082644b08994d6677d2277aec475755f5ac27 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 14:46:21 +0200 Subject: [PATCH 300/378] Move @phpstan-assert narrowing into DefaultNarrowingHelper, off TypeSpecifier::create specifyTypesFromAsserts was the last internal (engine) caller of TypeSpecifier::create()/specifyTypesInCondition - every other caller is a type-specifying extension or the @api filterByTruthyValue/filterByFalseyValue bridge. The assert composition now lives in DefaultNarrowingHelper: subjects and argument types are read through their ExpressionResults (Scope::obtainResultForNode - stored, or priced once for out-of-frame asks), so createSubjectTypes() composes with the result-carried structure (the impure remember-gate, the nullsafe plain twin) instead of create()'s old-world scope re-probing; the bool-constant sub-narrowing goes through specifyTypesForNode. Parameter defaults read TypeExpr's carried type directly. The two instanceof ConstantBooleanType checks (one moved, one left behind in specifyTypesFromConditionalReturnType) are rewritten with isTrue()/isFalse() and their baseline entry dropped; NewHandler's TypeSpecifier dependency became unused and is removed. --- phpstan-baseline.neon | 6 - src/Analyser/ExprHandler/FuncCallHandler.php | 4 +- .../Helper/DefaultNarrowingHelper.php | 151 ++++++++++++++++++ .../ExprHandler/MethodCallHandler.php | 2 +- src/Analyser/ExprHandler/NewHandler.php | 3 +- .../ExprHandler/StaticCallHandler.php | 2 +- src/Analyser/TypeSpecifier.php | 122 +------------- 7 files changed, 158 insertions(+), 132 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7d47f7da20a..9e4502ea289 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -96,12 +96,6 @@ parameters: count: 1 path: src/Analyser/RuleErrorTransformer.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 2 - path: src/Analyser/TypeSpecifier.php - - rawMessage: 'Template type TNodeType is declared as covariant, but occurs in contravariant position in parameter node of method PHPStan\Collectors\Collector::processNode().' identifier: generics.variance diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 168d9914d51..4494a039f0a 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -1132,7 +1132,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) @@ -1189,7 +1189,7 @@ private function specifyTypesFromCallableCall(NodeScopeResolver $nodeScopeResolv TemplateTypeVariance::createInvariant(), )); - return $this->typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + return $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); } /** diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index f49e6ef1bee..9288c327b5c 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -4,6 +4,8 @@ use Closure; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\CallLike; +use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticPropertyFetch; @@ -15,18 +17,25 @@ use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; +use PHPStan\ShouldNotHappenException; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\Node\IssetExpr; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Generic\TemplateType; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\NullType; @@ -34,7 +43,12 @@ use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\TypeCombinator; use PHPStan\Type\Type; +use function array_key_exists; +use function array_last; +use function array_map; use function array_reverse; +use function count; +use function substr; use function is_string; use function spl_object_id; @@ -480,4 +494,141 @@ public function createIssetSingleSubjectNonTrueTypes( + /** + * The narrowing a call's @phpstan-assert tags contribute - the new-world + * home of TypeSpecifier::specifyTypesFromAsserts(). Subjects and argument + * types are read through their ExpressionResults (stored, or priced once + * for out-of-frame asks), so createSubjectTypes() composes with the + * result-carried structure (impure gate, nullsafe twin) instead of + * create()'s scope re-probing. + */ + public function specifyTypesFromAsserts(TypeSpecifierContext $context, CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, MutatingScope $scope): ?SpecifiedTypes + { + if ($context->null()) { + $asserts = $assertions->getAsserts(); + } elseif ($context->true()) { + $asserts = $assertions->getAssertsIfTrue(); + } elseif ($context->false()) { + $asserts = $assertions->getAssertsIfFalse(); + } else { + throw new ShouldNotHappenException(); + } + + if (count($asserts) === 0) { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = array_last($parameters); + $paramName = $lastParameter->getName(); + } else { + continue; + } + + $argsMap[$paramName][] = $arg->value; + } + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $defaultValue = $parameter->getDefaultValue(); + if (isset($argsMap[$name]) || $defaultValue === null) { + continue; + } + $argsMap[$name][] = new TypeExpr($defaultValue); + } + + if ($call instanceof MethodCall) { + $argsMap['this'] = [$call->var]; + } + + $getArgType = static function (Expr $expr) use ($scope): Type { + if ($expr instanceof TypeExpr) { + return $expr->getExprType(); + } + + return $scope->obtainResultForNode($expr)->getTypeOnScope($scope, $scope->nativeTypesPromoted); + }; + + /** @var SpecifiedTypes|null $types */ + $types = null; + + foreach ($asserts as $assert) { + foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { + $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $getArgType): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $argsMap)) { + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + $argType = TypeCombinator::union(...array_map($getArgType, $argsMap[substr($type->getParameterName(), 1)])); + return $type->toConditional($argType); + } + return $type; + } + } + + return $traverse($type); + }); + + $assertExpr = $assert->getParameter()->getExpr($parameterExpr); + + $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $containsUnresolvedTemplate = false; + TypeTraverser::map( + $assert->getOriginalType(), + static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { + if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { + $resolvedType = $templateTypeMap->getType($type->getName()); + if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + $containsUnresolvedTemplate = true; + return $type; + } + } + + return $traverse($type); + }, + ); + + $subjectResult = $assertExpr instanceof TypeExpr + ? null + : $scope->obtainResultForNode($assertExpr); + $newTypes = $this->createSubjectTypes( + $scope, + $assertExpr, + $subjectResult, + $assertedType, + $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; + + if (!$context->null() || (!$assertedType->isTrue()->yes() && !$assertedType->isFalse()->yes())) { + continue; + } + + $subContext = $assertedType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + if ($assert->isNegated()) { + $subContext = $subContext->negate(); + } + + $types = $types->unionWith($this->specifyTypesForNode( + $scope, + $assertExpr, + $subContext, + )); + } + } + + return $types; + } + } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index c33e5030719..af2906915b2 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -487,7 +487,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index f31670254bf..37cd199f57f 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -82,7 +82,6 @@ public function __construct( #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, private ExpressionResultFactory $expressionResultFactory, - private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -737,7 +736,7 @@ private function specifyTypes(MutatingScope $scope, Expr $expr, ?ParametersAccep TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 09dc75eae4a..3670213307f 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -561,7 +561,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3be7b231478..28c0e504ef9 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -226,8 +226,8 @@ private function getConditionalSpecifiedTypes( $scope, ); - if ($targetType instanceof ConstantBooleanType) { - if (!$targetType->getValue()) { + if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { + if ($targetType->isFalse()->yes()) { $context = $context->negate(); } @@ -237,124 +237,6 @@ private function getConditionalSpecifiedTypes( return $specifiedTypes; } - /** @internal */ - public function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, Scope $scope): ?SpecifiedTypes - { - if ($context->null()) { - $asserts = $assertions->getAsserts(); - } elseif ($context->true()) { - $asserts = $assertions->getAssertsIfTrue(); - } elseif ($context->false()) { - $asserts = $assertions->getAssertsIfFalse(); - } else { - throw new ShouldNotHappenException(); - } - - if (count($asserts) === 0) { - return null; - } - - $argsMap = []; - $parameters = $parametersAcceptor->getParameters(); - foreach ($call->getArgs() as $i => $arg) { - if ($arg->unpack) { - continue; - } - - if ($arg->name !== null) { - $paramName = $arg->name->toString(); - } elseif (isset($parameters[$i])) { - $paramName = $parameters[$i]->getName(); - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { - $lastParameter = array_last($parameters); - $paramName = $lastParameter->getName(); - } else { - continue; - } - - $argsMap[$paramName][] = $arg->value; - } - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $defaultValue = $parameter->getDefaultValue(); - if (isset($argsMap[$name]) || $defaultValue === null) { - continue; - } - $argsMap[$name][] = new TypeExpr($defaultValue); - } - - if ($call instanceof MethodCall) { - $argsMap['this'] = [$call->var]; - } - - /** @var SpecifiedTypes|null $types */ - $types = null; - - foreach ($asserts as $assert) { - foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { - $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { - if ($type instanceof ConditionalTypeForParameter) { - $parameterName = substr($type->getParameterName(), 1); - if (array_key_exists($parameterName, $argsMap)) { - $type = $traverse($type); - if ($type instanceof ConditionalTypeForParameter) { - $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); - return $type->toConditional($argType); - } - return $type; - } - } - - return $traverse($type); - }); - - $assertExpr = $assert->getParameter()->getExpr($parameterExpr); - - $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); - $containsUnresolvedTemplate = false; - TypeTraverser::map( - $assert->getOriginalType(), - static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { - if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { - $resolvedType = $templateTypeMap->getType($type->getName()); - if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { - $containsUnresolvedTemplate = true; - return $type; - } - } - - return $traverse($type); - }, - ); - - $newTypes = $this->create( - $assertExpr, - $assertedType, - $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), - $scope, - )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); - $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; - - if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { - continue; - } - - $subContext = $assertedType->getValue() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); - if ($assert->isNegated()) { - $subContext = $subContext->negate(); - } - - $types = $types->unionWith($this->specifyTypesInCondition( - $scope, - $assertExpr, - $subContext, - )); - } - } - - return $types; - } - /** * @api */ From 34b74147d93af71cb90815f049c497dfac52c59e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 15:08:57 +0200 Subject: [PATCH 301/378] Move conditional-return-type narrowing into DefaultNarrowingHelper, off TypeSpecifier::create Same treatment as the @phpstan-assert move: specifyTypesFromConditionalReturnType and getConditionalSpecifiedTypes now compose through the argument's ExpressionResult (createSubjectTypes via obtainResultForNode) and specifyTypesForNode instead of TypeSpecifier::create()/specifyTypesInCondition. With this, no engine code narrows through TypeSpecifier's old-world entries - what remains are type-specifying extensions, the @api filterByTruthyValue/filterByFalseyValue bridge, and the documented handleDefaultTruthyOrFalseyContext structural fallback. The move exposed a latent gap in CoalesceHandler's createTypesCallback: when a type constraint rules the right side out and the narrowing delegates to the left side, the old createForExpr also emitted the coalesce's own key (the double-key the nullsafe handlers already document). Consumers summing the checked expression's own entry - ImpossibleCheckTypeHelper for is_string($x['k'] ?? null) (bug-8076) - rely on it; the delegate branch now unions the coalesce's own entry in. --- src/Analyser/ExprHandler/CoalesceHandler.php | 7 +- src/Analyser/ExprHandler/FuncCallHandler.php | 2 +- .../Helper/DefaultNarrowingHelper.php | 119 ++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 562b8e62dc6..01fe9e2544a 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -130,7 +130,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ($context->true() && $type->isSuperTypeOf($rightType)->no()) || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) ) { - return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, $type, $context); + // the coalesce's own key is emitted alongside the left-side + // narrowing (createForExpr's double-key, like the nullsafe + // handlers) - consumers summing the checked expression's own + // entry (ImpossibleCheckTypeHelper) rely on it + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)); } } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 4494a039f0a..7a3acfba163 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -1118,7 +1118,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop } if (count($args) > 0 && $resolvedParametersAcceptor !== null) { - $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 9288c327b5c..a68040e767b 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -3,7 +3,9 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use Closure; +use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\ArrayDimFetch; @@ -27,17 +29,20 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\TypeTraverser; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticTypeFactory; @@ -49,7 +54,9 @@ use function array_reverse; use function count; use function substr; +use function in_array; use function is_string; +use function strtolower; use function spl_object_id; /** @@ -631,4 +638,116 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai return $types; } + /** + * The narrowing a conditional return type (`($x is Foo ? true : false)`) + * contributes to its argument - the new-world home of + * TypeSpecifier::specifyTypesFromConditionalReturnType(). The argument's + * narrowing composes through its ExpressionResult. + */ + public function specifyTypesFromConditionalReturnType( + TypeSpecifierContext $context, + Expr\CallLike $call, + ParametersAcceptor $parametersAcceptor, + MutatingScope $scope, + ): ?SpecifiedTypes + { + if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { + return null; + } + + $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); + if (!$returnType instanceof ConditionalTypeForParameter) { + return null; + } + + if ($context->true()) { + $leftType = new ConstantBooleanType(true); + $rightType = new ConstantBooleanType(false); + } elseif ($context->false()) { + $leftType = new ConstantBooleanType(false); + $rightType = new ConstantBooleanType(true); + } elseif ($context->null()) { + $leftType = new MixedType(); + $rightType = new NeverType(); + } else { + return null; + } + + $argumentExpr = null; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } else { + continue; + } + + if ($returnType->getParameterName() !== '$' . $paramName) { + continue; + } + + $argumentExpr = $arg->value; + } + + if ($argumentExpr === null) { + return null; + } + + return $this->getConditionalSpecifiedTypes($returnType, $leftType, $rightType, $scope, $argumentExpr); + } + + private function getConditionalSpecifiedTypes( + ConditionalTypeForParameter $conditionalType, + Type $leftType, + Type $rightType, + MutatingScope $scope, + Expr $argumentExpr, + ): ?SpecifiedTypes + { + $targetType = $conditionalType->getTarget(); + $ifType = $conditionalType->getIf(); + $elseType = $conditionalType->getElse(); + + if ( + ( + $argumentExpr instanceof Node\Scalar + || ($argumentExpr instanceof ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) + ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) + ) { + return null; + } + + if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); + } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + } else { + return null; + } + + $specifiedTypes = $this->createSubjectTypes( + $scope, + $argumentExpr, + $scope->obtainResultForNode($argumentExpr), + $targetType, + $context, + ); + + if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { + if ($targetType->isFalse()->yes()) { + $context = $context->negate(); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->specifyTypesForNode($scope, $argumentExpr, $context)); + } + + return $specifiedTypes; + } + } From 34bd2613ea342db4aa83bff9983b7499c6ac80ea Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 15:13:28 +0200 Subject: [PATCH 302/378] Finish the conditional-return-type move: remaining call sites + delete the TypeSpecifier original MethodCallHandler and StaticCallHandler now use DefaultNarrowingHelper::specifyTypesFromConditionalReturnType like FuncCallHandler already did; TypeSpecifier's specifyTypesFromConditionalReturnType/getConditionalSpecifiedTypes are deleted along with their now-unused imports. TypeSpecifier's create() and specifyTypesInCondition() are now called only by type-specifying extensions, the @api filterByTruthyValue/filterByFalseyValue bridge, and the handleDefaultTruthyOrFalseyContext structural fallback. --- .../ExprHandler/MethodCallHandler.php | 2 +- .../ExprHandler/StaticCallHandler.php | 2 +- src/Analyser/TypeSpecifier.php | 110 ------------------ 3 files changed, 2 insertions(+), 112 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index af2906915b2..e2298910a36 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -473,7 +473,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop } if (count($args) > 0 && $resolvedParametersAcceptor !== null) { - $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 3670213307f..b143f056571 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -547,7 +547,7 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop } if (count($args) > 0 && $resolvedParametersAcceptor !== null) { - $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 28c0e504ef9..40284ed003c 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -18,20 +18,16 @@ use PHPStan\Reflection\Assertions; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ReflectionProvider; -use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\MethodTypeSpecifyingExtension; -use PHPStan\Type\MixedType; -use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; use PHPStan\Type\StaticTypeFactory; @@ -131,112 +127,6 @@ public function handleDefaultTruthyOrFalseyContext(TypeSpecifierContext $context return (new SpecifiedTypes([], []))->setRootExpr($expr); } - /** @internal */ - public function specifyTypesFromConditionalReturnType( - TypeSpecifierContext $context, - Expr\CallLike $call, - ParametersAcceptor $parametersAcceptor, - Scope $scope, - ): ?SpecifiedTypes - { - if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { - return null; - } - - $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); - if (!$returnType instanceof ConditionalTypeForParameter) { - return null; - } - - if ($context->true()) { - $leftType = new ConstantBooleanType(true); - $rightType = new ConstantBooleanType(false); - } elseif ($context->false()) { - $leftType = new ConstantBooleanType(false); - $rightType = new ConstantBooleanType(true); - } elseif ($context->null()) { - $leftType = new MixedType(); - $rightType = new NeverType(); - } else { - return null; - } - - $argumentExpr = null; - $parameters = $parametersAcceptor->getParameters(); - foreach ($call->getArgs() as $i => $arg) { - if ($arg->unpack) { - continue; - } - - if ($arg->name !== null) { - $paramName = $arg->name->toString(); - } elseif (isset($parameters[$i])) { - $paramName = $parameters[$i]->getName(); - } else { - continue; - } - - if ($returnType->getParameterName() !== '$' . $paramName) { - continue; - } - - $argumentExpr = $arg->value; - } - - if ($argumentExpr === null) { - return null; - } - - return $this->getConditionalSpecifiedTypes($returnType, $leftType, $rightType, $scope, $argumentExpr); - } - - private function getConditionalSpecifiedTypes( - ConditionalTypeForParameter $conditionalType, - Type $leftType, - Type $rightType, - Scope $scope, - Expr $argumentExpr, - ): ?SpecifiedTypes - { - $targetType = $conditionalType->getTarget(); - $ifType = $conditionalType->getIf(); - $elseType = $conditionalType->getElse(); - - if ( - ( - $argumentExpr instanceof Node\Scalar - || ($argumentExpr instanceof ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) - ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) - ) { - return null; - } - - if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { - $context = $conditionalType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); - } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { - $context = $conditionalType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); - } else { - return null; - } - - $specifiedTypes = $this->create( - $argumentExpr, - $targetType, - $context, - $scope, - ); - - if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { - if ($targetType->isFalse()->yes()) { - $context = $context->negate(); - } - - $specifiedTypes = $specifiedTypes->unionWith($this->specifyTypesInCondition($scope, $argumentExpr, $context)); - } - - return $specifiedTypes; - } - /** * @api */ From 4b66947d3378619caef0f6e2c52638cbce0a5072 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 15:30:13 +0200 Subject: [PATCH 303/378] Gate call-shaped narrowing subjects by reflection instead of walking them The assert and conditional-return-type narrowing obtained every subject's ExpressionResult via obtainResultForNode, which walks the node when it is not stored - and derived assert subjects (the fresh nodes AssertTagParameter::getExpr() builds, e.g. Scope::isInAnonymousFunction()'s assert on getAnonymousFunctionReflection()) are never stored, so every truthy/falsey ask of such a condition re-walked a call subtree. The result was only needed for the impure remember-gate: replace the walk with a stored-only lookup plus isRememberableCallSubject() - the walk-free reflection equivalent (hasSideEffects via function/method reflection, the receiver read from scope state), which is where the walked result's impure points came from anyway. Argument types for conditional-type resolution read stored results first and fall back to scope state. Per-Expr census on NodeScopeResolver.php: the worst node drops 106x -> 70x (the 36x assert-walk channel disappears). --- .../Helper/DefaultNarrowingHelper.php | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index a68040e767b..87af363eb65 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -7,6 +7,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\CallLike; +use PhpParser\Node\Name; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\PropertyFetch; @@ -29,6 +30,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\Type\Accessory\HasOffsetType; @@ -76,6 +78,7 @@ public function __construct( private ExprPrinter $exprPrinter, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ReflectionProvider $reflectionProvider, ) { } @@ -563,7 +566,12 @@ public function specifyTypesFromAsserts(TypeSpecifierContext $context, CallLike return $expr->getExprType(); } - return $scope->obtainResultForNode($expr)->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $result = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($expr); + if ($result !== null) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $scope->getStateType($expr); }; /** @var SpecifiedTypes|null $types */ @@ -608,7 +616,13 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $subjectResult = $assertExpr instanceof TypeExpr ? null - : $scope->obtainResultForNode($assertExpr); + : $scope->getCurrentExpressionResultStorage()?->findExpressionResult($assertExpr); + if ($subjectResult === null && $assertExpr instanceof CallLike && !$this->isRememberableCallSubject($scope, $assertExpr)) { + // a call subject whose value must not be remembered (side + // effects) contributes no narrowing - old create()'s purity + // gate, derived from reflection instead of a walk + continue; + } $newTypes = $this->createSubjectTypes( $scope, $assertExpr, @@ -731,10 +745,15 @@ private function getConditionalSpecifiedTypes( return null; } + $argumentResult = $scope->getCurrentExpressionResultStorage()?->findExpressionResult($argumentExpr); + if ($argumentResult === null && $argumentExpr instanceof CallLike && !$this->isRememberableCallSubject($scope, $argumentExpr)) { + // old create()'s purity gate, derived from reflection instead of a walk + return null; + } $specifiedTypes = $this->createSubjectTypes( $scope, $argumentExpr, - $scope->obtainResultForNode($argumentExpr), + $argumentResult, $targetType, $context, ); @@ -750,4 +769,40 @@ private function getConditionalSpecifiedTypes( return $specifiedTypes; } + /** + * Whether a call subject's value may be remembered by narrowing - the + * walk-free equivalent of the impure gate a stored ExpressionResult + * carries: a call with (possible) side effects yields a different value + * next time, so pinning a type to its expression string would lie. + */ + private function isRememberableCallSubject(MutatingScope $scope, Expr $expr): bool + { + if ($expr instanceof Expr\FuncCall && $expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return false; + } + $hasSideEffects = $this->reflectionProvider->getFunction($expr->name, $scope)->hasSideEffects(); + } elseif ($expr instanceof Expr\MethodCall && $expr->name instanceof Identifier) { + $methodReflection = $scope->getMethodReflection($scope->getStateType($expr->var), $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + } elseif ($expr instanceof Expr\StaticCall && $expr->name instanceof Identifier && $expr->class instanceof Name) { + $methodReflection = $scope->getMethodReflection($scope->resolveTypeByName($expr->class), $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + } else { + return false; + } + + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + } From 536cab103d228b792308497da618befe32a2d11d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 16:02:56 +0200 Subject: [PATCH 304/378] Memoize the narrowing-application pricing pair per scope applySpecifiedTypes() prices an untracked call-shaped narrowing subject on demand via getCurrentTypesOfSpecifiedExpr(); the truthy and the falsey application of one narrowing - and every later ask on the same scope - repeated the walk. One walk now answers both flavours and the pair is memoized per scope instance keyed by the node key, mirroring how getType() memoizes in resolvedTypes. (Routing the miss through getType()/getNativeType() instead was measured and rejected: getNativeType prices on the promoted twin scope with its own cache, doubling the walks for fresh subjects.) Per-Expr census on NodeScopeResolver.php: worst node 70x -> 61x, 11+-walk bucket 91 -> 87 nodes. --- src/Analyser/MutatingScope.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index be5da0ccbd1..f7aef0a5afc 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -147,6 +147,9 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter */ public array $resolvedTypes = []; + /** @var array */ + private array $pricedSpecifiedExprTypePairs = []; + /** @var array */ private array $truthyScopes = []; @@ -1253,8 +1256,15 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array ]; } - // a synthetic node - price it on demand, see - // resolveTypeOfNewWorldHandlerNode() + // a call subject (or a synthetic plain-chain variant) is priced on + // demand once per scope: one walk answers both flavours, and the + // truthy and falsey applications of one narrowing - and every later + // ask on this scope - reuse the pair + $key = $this->getNodeKey($expr); + if (array_key_exists($key, $this->pricedSpecifiedExprTypePairs)) { + return $this->pricedSpecifiedExprTypePairs[$key]; + } + $scope = $this->toMutatingScope(); $result = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( $expr, @@ -1262,7 +1272,7 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array $storage->duplicate(), ); - return [ + return $this->pricedSpecifiedExprTypePairs[$key] = [ $result->getTypeOnScope($scope, $scope->nativeTypesPromoted), $result->getTypeOnScope($scope, true), ]; From 5add00fc6f6622ba3b6cdf3be181d0cda9920aef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 20:47:51 +0200 Subject: [PATCH 305/378] Replace SpecifiedTypes::normalize() with symbolic alternative-form entries The either-branch merge (intersectWith) no longer needs the scope-eager normalize() pass: same-kind constraints merge exactly (sure: union, sure-not: intersect, vacuous never-subtractions collapse), and an expression constrained with different kinds on the two sides becomes an alternative-form entry - a list of (sure ?? current) minus subtract terms united by MutatingScope::applySpecifiedTypes() against the subject's type at the application point. This removes the last old-world scope dependency from narrowing composition: a condition's SpecifiedTypes now describe the narrowing symbolically instead of baking in the types of whatever scope the composition happened to run on. Consequences swept along: - specifyConjunction asks the right operand's narrowing on the left-truthy scope, matching the operand's real position after the left is known true (previously the raw ask scope - count()/strlen() family narrowing in `is_array($x) && count($x) >= 2` read mixed for the argument and dropped the non-empty accessory). - getCurrentTypesOfSpecifiedExpr prefers the scope-state view of narrowable expressions over stored walk-position results - entry nodes inside conditions are walked on branch scopes, and their stored types must not poison narrowing bases at the application point. - Decided-comparison paths that need concrete sure types use DefaultNarrowingHelper::toSureTypes() with their own compose-time evaluation scope - unlike normalize(), never the application point. - Conditional-expression-holder building bails on alternative-form conditions (no single condition type to track) and skips the branch-scope union recovery for expressions the exact merge already constrains. The four TypeSpecifierTest known-reds (#85, #106, #107, #109) now pass, as does MatchExpressionRuleTest::testBug13303. Updated expectations that encoded normalize() artifacts: bug-3991's native type (the file itself documented the correct value), bug-9961's double-wrapped template type description, ImpossibleInstanceOfRule's missing PHPDoc tip (natively the type no longer collapses), and falsey-empty-certainty where `empty($foo)` with a truthy-when-defined subject now correctly proves the variable undefined. --- phpstan-baseline.neon | 2 +- .../Helper/BooleanNarrowingHelper.php | 40 ++-- .../ConditionalExpressionHolderHelper.php | 15 ++ .../Helper/DefaultNarrowingHelper.php | 28 +++ .../Helper/IdenticalNarrowingHelper.php | 6 +- .../ExprHandler/NullsafeMethodCallHandler.php | 4 +- .../NullsafePropertyFetchHandler.php | 4 +- src/Analyser/MutatingScope.php | 82 ++++++-- src/Analyser/SpecifiedTypes.php | 185 ++++++++++++++---- ...InArrayFunctionTypeSpecifyingExtension.php | 2 +- tests/PHPStan/Analyser/TypeSpecifierTest.php | 25 ++- tests/PHPStan/Analyser/nsrt/bug-3991.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-9961.php | 4 +- .../Analyser/nsrt/falsey-empty-certainty.php | 8 +- .../Classes/ImpossibleInstanceOfRuleTest.php | 1 + 15 files changed, 314 insertions(+), 98 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9e4502ea289..8cf37634f54 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -51,7 +51,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 3 + count: 4 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index d8290e22e82..09040e32c2c 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -48,15 +48,15 @@ public function specifyConjunction( ): SpecifiedTypes { $leftTypes = $leftTypesCallback($s, $context)->setRootExpr($rootExpr); + // the right operand lives after the left is known true - its narrowing + // bases read from the left-truthy view, never the raw ask scope $rightScope = $leftTruthyScope; - $rightTypes = $rightTypesCallback($s, $context)->setRootExpr($rootExpr); + $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); } else { - $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); - $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); - $types = $leftNormalized->intersectWith($rightNormalized); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftFalseyScope, $rightFalseyScope, $types); + $types = $leftTypes->intersectWith($rightTypes); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, $leftFalseyScope, $rightFalseyScope, $types); } if ($context->false()) { // Consequent (holder) narrowings projected by each holder: these must be @@ -94,10 +94,10 @@ public function specifyConjunction( $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); } } - $result = new SpecifiedTypes( + $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ); + ))->withAlternativeTypesOf($types); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } @@ -144,20 +144,19 @@ public function specifyDisjunction( $rightScope = $leftFalseyScope; $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); + if ($context->true()) { if ( $leftTypeCallback($s)->toBoolean()->isFalse()->yes() ) { - $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); + $types = $rightTypes; } elseif ( $leftTypeCallback($s)->toBoolean()->isTrue()->yes() || $rightTypeCallback($s)->toBoolean()->isFalse()->yes() ) { - $types = $leftTypes->normalize($s, $nodeScopeResolver); + $types = $leftTypes; } else { - $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); - $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); - $types = $leftNormalized->intersectWith($rightNormalized); + $types = $leftTypes->intersectWith($rightTypes); $types = $this->augmentDisjunctionTruthyWithConditionalHolders( $nodeScopeResolver, $s, @@ -166,17 +165,17 @@ public function specifyDisjunction( $rightTruthyScope, $rootExpr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftTruthyScope, $rightTruthyScope, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, $leftTruthyScope, $rightTruthyScope, $types); } } else { $types = $leftTypes->unionWith($rightTypes); } if ($context->true()) { - $result = new SpecifiedTypes( + $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ); + ))->withAlternativeTypesOf($types); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } @@ -213,6 +212,12 @@ private function augmentDisjunctionTruthyWithConditionalHolders( $seen[$rootExprString] = true; $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); + // the exact either-branch merge already constrains this + // expression - do not add the weaker branch-scope union on top + if (isset($types->getAlternativeTypes()[$rootExprString])) { + continue; + } + // Only project when the target stays Yes-defined in the original // scope and in both filtered branches. A sure type implicitly // raises certainty to Yes, which would wrongly upgrade Maybe-defined @@ -255,6 +260,11 @@ private function augmentDisjunctionTruthyWithConditionalHolders( private function allExpressionsTrackable(SpecifiedTypes $types): bool { + // an alternative-form entry has no single condition type to track + if ($types->getAlternativeTypes() !== []) { + return false; + } + foreach ($types->getSureTypes() as [$expr]) { if (!$this->isTrackableExpression($expr)) { return false; diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 4f51c2d2f1e..a93957afb7e 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -54,12 +54,20 @@ public function augmentDisjunctionTypes( } $existingSureTypes = $types->getSureTypes(); + $existingAlternativeTypes = $types->getAlternativeTypes(); $viableCandidates = []; foreach ($candidateExprs as $exprString => $targetExpr) { if (isset($existingSureTypes[$exprString])) { continue; } + // the exact either-branch merge already constrains this expression + // (an alternative-form entry) - the branch-scope union recovery + // below is the old lossy-merge compensation and would only add a + // weaker entry on top + if (isset($existingAlternativeTypes[$exprString])) { + continue; + } if (!$scope->hasExpressionType($targetExpr)->yes()) { continue; } @@ -142,6 +150,13 @@ public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResol // single "this side is true" condition, so they must be gathered together // into one condition set. Picking only one list would drop a conjunct and // let the resulting holder fire too eagerly. + // an alternative-form entry (a cross-kind either-branch merge) has no + // single condition type; dropping it from the condition set would let + // the holder fire too eagerly - build no holders from such a condition + if ($conditionSpecifiedTypes->getAlternativeTypes() !== []) { + return []; + } + $conditionExpressionTypes = []; $droppedNoOpConditions = []; // the unnarrowed type of each condition expression, pinned at diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 87af363eb65..a7d6a20c66d 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -118,6 +118,34 @@ public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): ]))->setRootExpr($expr); } + /** + * Converts sure-not entries to sure form against the given evaluation + * scope (position-fixed, captured at compose time) - for the decided + * comparison paths whose consumers need a concrete sure type. This is NOT + * the deleted SpecifiedTypes::normalize(): the scope here is the + * narrowing's own evaluation position, never the application point. + */ + public function toSureTypes(SpecifiedTypes $types, MutatingScope $evaluationScope): SpecifiedTypes + { + $sureTypes = $types->getSureTypes(); + + foreach ($types->getSureNotTypes() as $exprString => [$exprNode, $sureNotType]) { + if (!isset($sureTypes[$exprString])) { + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($evaluationScope->getStateType($exprNode), $sureNotType)]; + continue; + } + + $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); + } + + $result = new SpecifiedTypes($sureTypes, []); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($types->getRootExpr()); + } + /** * The new-world counterpart of TypeSpecifier::create() for a subject the * calling handler has already processed. The subject's own result says how diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php index 98e26e2d5a1..fa5270db911 100644 --- a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -527,8 +527,8 @@ private function specifyGeneral( $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context), ); } elseif ($context->false()) { - return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context)->normalize($evaluationScope, $nodeScopeResolver) - ->intersectWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context)->normalize($evaluationScope, $nodeScopeResolver)); + return $this->defaultNarrowingHelper->toSureTypes($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context), $evaluationScope) + ->intersectWith($this->defaultNarrowingHelper->toSureTypes($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context), $evaluationScope)); } return new SpecifiedTypes([], []); @@ -621,7 +621,7 @@ public function specifyEqual( return $context->true() ? $leftTypes->unionWith($rightTypes) - : $leftTypes->normalize($evaluationScope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($evaluationScope, $nodeScopeResolver)); + : $this->defaultNarrowingHelper->toSureTypes($leftTypes, $evaluationScope)->intersectWith($this->defaultNarrowingHelper->toSureTypes($rightTypes, $evaluationScope)); } /** diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 5da936108c6..9d2ab8c55f3 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -122,7 +122,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } @@ -137,7 +137,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); }, // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 40aa5ef43d3..e76b0e29135 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -102,7 +102,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } @@ -117,7 +117,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); }, // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f7aef0a5afc..e5448015e32 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1238,23 +1238,26 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array return null; } + // a narrowable expression's scope-view type is derived from tracked + // state - the application-point semantics this method exists for. The + // stored result must NOT win here: a narrowing entry's node sits inside + // the condition (the \$a of `'' !== \$a`, walked on a truthy branch), so + // its walk-position type carries branch narrowing that would poison the + // base the narrowing is applied to. + if ( + ($expr instanceof Expr\Variable && is_string($expr->name)) + || $expr instanceof PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch + ) { + return [ + $this->resolveScopeStateType($expr, $this->nativeTypesPromoted), + $this->resolveScopeStateType($expr, true), + ]; + } + $result = $storage->findExpressionResult($expr); if ($result === null) { - // a narrowable expression's scope-view type is derived from tracked - // state - no need to price the node on demand (the storage misses - // whenever the narrowing is applied on a different frame than the - // walk that produced the subject) - if ( - ($expr instanceof Expr\Variable && is_string($expr->name)) - || $expr instanceof PropertyFetch - || $expr instanceof Expr\ArrayDimFetch - || $expr instanceof Expr\StaticPropertyFetch - ) { - return [ - $this->resolveScopeStateType($expr, $this->nativeTypesPromoted), - $this->resolveScopeStateType($expr, true), - ]; - } // a call subject (or a synthetic plain-chain variant) is priced on // demand once per scope: one walk answers both flavours, and the @@ -3566,6 +3569,17 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self 'type' => $type, ]; } + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$expr, $terms]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'terms' => $terms, + ]; + } usort($typeSpecifications, static function (array $a, array $b): int { $length = strlen($a['exprString']) - strlen($b['exprString']); @@ -3580,7 +3594,6 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; - $type = $typeSpecification['type']; $exprString = $typeSpecification['exprString']; if ($expr instanceof IssetExpr) { @@ -3640,6 +3653,43 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } + if (isset($typeSpecification['terms'])) { + // an alternative-form entry: the union over its terms of + // `(sure ?? current) minus subtract`, evaluated here at the + // application point - the deferred descendant of the old + // SpecifiedTypes::normalize() + $evaluate = static function (?Type $current) use ($typeSpecification): ?Type { + $parts = []; + foreach ($typeSpecification['terms'] as [$sure, $subtract]) { + $base = $sure ?? $current; + if ($base === null) { + return null; + } + $parts[] = $subtract !== null ? TypeCombinator::remove($base, $subtract) : $base; + } + + return TypeCombinator::union(...$parts); + }; + $evaluated = $evaluate($trackedType); + if ($evaluated === null) { + // a current-type-dependent term with no known current type - + // nothing sound to specify (mirrors the sure-not behaviour) + continue; + } + $evaluatedNative = $evaluate($trackedNativeType ?? $trackedType) ?? $evaluated; + + $newType = $trackedType !== null ? TypeCombinator::intersect($evaluated, $trackedType) : $evaluated; + $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($evaluatedNative, $trackedNativeType) : $evaluatedNative; + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + + $holderType = array_key_exists($exprString, $scope->expressionTypes) + ? $scope->expressionTypes[$exprString]->getType() + : $newType; + $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); + continue; + } + + $type = $typeSpecification['type']; if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 20ef5dfa1bc..29ab4f1b856 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_exists; @@ -18,6 +19,19 @@ final class SpecifiedTypes private ?Expr $rootExpr = null; + /** + * Alternative-form entries produced by intersectWith() when the two sides + * constrain the same expression with different kinds (a sure type in one + * branch, a sure-not in the other). Each term (sure, subtract) reads as + * `(sure ?? current type) minus subtract`; the entry's value is the union + * of its terms, evaluated by MutatingScope::applySpecifiedTypes() against + * the subject's type at the application point - the deferred form of what + * the old SpecifiedTypes::normalize() computed eagerly with a scope. + * + * @var array}> + */ + private array $alternativeTypes = []; + /** * @api * @param array $sureTypes @@ -50,6 +64,7 @@ public function __construct( public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -63,6 +78,7 @@ public function setAlwaysOverwriteTypes(): self public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -76,6 +92,7 @@ public function setRootExpr(?Expr $rootExpr): self public function setNewConditionalExpressionHolders(array $newConditionalExpressionHolders): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -101,6 +118,30 @@ public function getSureNotTypes(): array return $this->sureNotTypes; } + /** + * @return array}> + */ + public function getAlternativeTypes(): array + { + return $this->alternativeTypes; + } + + /** + * A copy of this with the other's alternative-form entries - for the + * composition tails that rebuild a SpecifiedTypes from the sure/sure-not + * slots and must not drop the merged alternatives. + */ + public function withAlternativeTypesOf(self $other): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $other->alternativeTypes; + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + public function shouldOverwrite(): bool { return $this->overwrite; @@ -123,10 +164,13 @@ public function removeExpr(string $exprString): self { $sureTypes = $this->sureTypes; $sureNotTypes = $this->sureNotTypes; + $alternativeTypes = $this->alternativeTypes; unset($sureTypes[$exprString]); unset($sureNotTypes[$exprString]); + unset($alternativeTypes[$exprString]); $self = new self($sureTypes, $sureNotTypes); + $self->alternativeTypes = $alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -134,36 +178,77 @@ public function removeExpr(string $exprString): self return $self; } - /** @api */ + /** + * The either-branch merge: the result holds when at least one side holds + * (the falsey narrowing of `&&`, the truthy narrowing of `||`). Same-kind + * constraints merge exactly (sure: union of values, sure-not: intersection + * of removed types); an expression constrained with different kinds on the + * two sides becomes an alternative-form entry - `(sure ?? current) minus + * subtract` per side, united at the application point. An expression + * constrained on only one side is unconstrained in the merge. + * + * @api + */ public function intersectWith(SpecifiedTypes $other): self { $sureTypeUnion = []; $sureNotTypeUnion = []; + $alternativeUnion = []; $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); - foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { - if (!isset($other->sureTypes[$exprString])) { - continue; + $keys = []; + foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes, $other->sureTypes, $other->sureNotTypes, $other->alternativeTypes] as $map) { + foreach ($map as $exprString => $entry) { + $keys[$exprString] = $entry[0]; } - - $sureTypeUnion[$exprString] = [ - $exprNode, - TypeCombinator::union($type, $other->sureTypes[$exprString][1]), - ]; } - foreach ($this->sureNotTypes as $exprString => [$exprNode, $type]) { - if (!isset($other->sureNotTypes[$exprString])) { + foreach ($keys as $exprString => $exprNode) { + $thisTerms = $this->collectTerms($exprString); + $otherTerms = $other->collectTerms($exprString); + if ($thisTerms === null || $otherTerms === null) { + // unconstrained on one side - unconstrained in the merge continue; } - $sureNotTypeUnion[$exprString] = [ - $exprNode, - TypeCombinator::intersect($type, $other->sureNotTypes[$exprString][1]), - ]; + $terms = array_merge($thisTerms, $otherTerms); + $sures = []; + $subtracts = []; + $pureSure = true; + $pureSureNot = true; + foreach ($terms as [$sure, $subtract]) { + if ($sure === null) { + $pureSure = false; + } else { + $sures[] = $sure; + } + if ($subtract === null) { + $pureSureNot = false; + } else { + $subtracts[] = $subtract; + } + if ($sure !== null && $subtract !== null) { + $pureSure = false; + $pureSureNot = false; + } + } + + if ($pureSure) { + $sureTypeUnion[$exprString] = [$exprNode, TypeCombinator::union(...$sures)]; + } elseif ($pureSureNot) { + $merged = TypeCombinator::intersect(...$subtracts); + if ($merged instanceof NeverType) { + // removing never removes nothing - a vacuous constraint + continue; + } + $sureNotTypeUnion[$exprString] = [$exprNode, $merged]; + } else { + $alternativeUnion[$exprString] = [$exprNode, $terms]; + } } $result = new self($sureTypeUnion, $sureNotTypeUnion); + $result->alternativeTypes = $alternativeUnion; if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -171,6 +256,48 @@ public function intersectWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } + /** + * This side's constraint on the expression as alternative-form terms, or + * null when unconstrained. A sure and a sure-not on the same key are one + * term (the sure with the sure-not removed) - both constraints hold here. + * + * @return list|null + */ + private function collectTerms(string|int $exprString): ?array + { + if (isset($this->alternativeTypes[$exprString])) { + $terms = $this->alternativeTypes[$exprString][1]; + // sure/sureNot on the same key as an alternative entry: fold them + // into every term (they hold in addition to the alternatives) + if (isset($this->sureTypes[$exprString]) || isset($this->sureNotTypes[$exprString])) { + $extraSure = $this->sureTypes[$exprString][1] ?? null; + $extraSubtract = $this->sureNotTypes[$exprString][1] ?? null; + $folded = []; + foreach ($terms as [$sure, $subtract]) { + if ($extraSure !== null) { + $sure = $sure === null ? $extraSure : TypeCombinator::intersect($sure, $extraSure); + } + if ($extraSubtract !== null) { + $subtract = $subtract === null ? $extraSubtract : TypeCombinator::union($subtract, $extraSubtract); + } + $folded[] = [$sure, $subtract]; + } + + return $folded; + } + + return $terms; + } + + $sure = $this->sureTypes[$exprString][1] ?? null; + $subtract = $this->sureNotTypes[$exprString][1] ?? null; + if ($sure === null && $subtract === null) { + return null; + } + + return [[$sure, $subtract]]; + } + /** @api */ public function unionWith(SpecifiedTypes $other): self { @@ -201,6 +328,7 @@ public function unionWith(SpecifiedTypes $other): self } $result = new self($sureTypeUnion, $sureNotTypeUnion); + $result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -218,33 +346,6 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - public function normalize(Scope $scope, ?NodeScopeResolver $nodeScopeResolver = null): self - { - $sureTypes = $this->sureTypes; - - foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { - if (!isset($sureTypes[$exprString])) { - // $nodeScopeResolver marks the inside-out (engine) path: the subject's - // current type is derived from the scope's tracked state without - // processing the node. Extensions calling without it read normally. - $exprType = $nodeScopeResolver !== null - ? $scope->toMutatingScope()->getStateType($exprNode) - : $scope->getType($exprNode); - $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($exprType, $sureNotType)]; - continue; - } - - $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); - } - - $result = new self($sureTypes, []); - if ($this->overwrite) { - $result = $result->setAlwaysOverwriteTypes(); - } - - return $result->setRootExpr($this->rootExpr); - } - private function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr { if ($rootExprA === $rootExprB) { diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index d6e3f0e3587..ad857c7086f 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -91,7 +91,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n } $combinedMultipleItems = true; - $types = $context->true() ? $types->normalize($scope)->intersectWith($itemTypes->normalize($scope)) : $types->unionWith($itemTypes); + $types = $context->true() ? $types->intersectWith($itemTypes) : $types->unionWith($itemTypes); } if ($types !== null) { diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 50ac5274f3a..ffc5a74204c 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -34,6 +34,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; @@ -377,7 +378,7 @@ public static function dataCondition(): iterable self::createFunctionCall('random'), ), [], - ['$foo' => 'mixed'], + [], ], [ new Expr\BinaryOp\BooleanOr( @@ -409,7 +410,7 @@ public static function dataCondition(): iterable ), self::createFunctionCall('random'), ), - ['$foo' => 'mixed'], + [], [], ], @@ -653,7 +654,7 @@ public static function dataCondition(): iterable [ new Expr\Empty_(new Variable('array')), [ - '$array' => 'array{}', + '$array' => '~mixed~(0|0.0|\'\'|\'0\'|array{}|false|null)', ], [ '$array' => '~0|0.0|\'\'|\'0\'|array{}|false|null', @@ -665,7 +666,7 @@ public static function dataCondition(): iterable '$array' => '~0|0.0|\'\'|\'0\'|array{}|false|null', ], [ - '$array' => 'array{}', + '$array' => '~mixed~(0|0.0|\'\'|\'0\'|array{}|false|null)', ], ], [ @@ -1209,7 +1210,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-string|null'], - ['$a' => 'mixed~non-empty-string & ~null'], + ['$a' => '~non-empty-string|null'], ], [ new Expr\BinaryOp\BooleanOr( @@ -1223,7 +1224,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-array|null'], - ['$a' => 'mixed~non-empty-array & ~null'], + ['$a' => '~non-empty-array|null'], ], [ new Expr\BinaryOp\BooleanAnd( @@ -1337,6 +1338,18 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array $typesDescription[$exprString][] = $exprType->describe(VerbosityLevel::precise()); } + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$exprNode, $terms]) { + // evaluate the alternative-form entry against the test scope, the + // same way applySpecifiedTypes() evaluates it at the application + // point - the readable result matches the old eager normalize form + $parts = []; + foreach ($terms as [$sure, $subtract]) { + $base = $sure ?? $this->scope->getType($exprNode); + $parts[] = $subtract !== null ? TypeCombinator::remove($base, $subtract) : $base; + } + $typesDescription[$exprString][] = TypeCombinator::union(...$parts)->describe(VerbosityLevel::precise()); + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$exprNode, $exprType]) { $typesDescription[$exprString][] = '~' . $exprType->describe(VerbosityLevel::precise()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-3991.php b/tests/PHPStan/Analyser/nsrt/bug-3991.php index e1a79f6937d..e42d47fa024 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3991.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3991.php @@ -18,11 +18,7 @@ public static function email($config = null) assertType('array|stdClass|null', $config); if (empty($config)) { - // the native type should be `0|0.0|''|'0'|array{}|false|null` - // the problem is that `empty($config)` translates to `!isset($config) || !$config` - // and before specified types of the left and right side are intersected they are "normalized" - // by removing the sureNotType from $scope->getType() which is `stdClass|array|null` here - assertNativeType('array{}|null', $config); + assertNativeType('0|0.0|\'\'|\'0\'|array{}|false|null', $config); assertType('array{}|null', $config); $config = new \stdClass(); } elseif (! (is_array($config) || $config instanceof \stdClass)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-9961.php b/tests/PHPStan/Analyser/nsrt/bug-9961.php index cd410c83e0d..2ec01ea9ca3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9961.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9961.php @@ -25,12 +25,12 @@ public function sayHello(Ia|Ic $a): mixed if ($a instanceof Ic && $a instanceof Id) { assertType('T of Bug9961\Ic&Bug9961\Id (method Bug9961\HelloWorld::sayHello(), argument)', $a); } elseif ($a instanceof A) { - assertType('Bug9961\A&T of T of Bug9961\Ia&Bug9961\Ib (method Bug9961\HelloWorld::sayHello(), argument) (method Bug9961\HelloWorld::sayHello(), argument)', $a); + assertType('Bug9961\A&T of Bug9961\Ia&Bug9961\Ib (method Bug9961\HelloWorld::sayHello(), argument)', $a); } else { throw new \Exception; } - assertType('(Bug9961\A&T of T of Bug9961\Ia&Bug9961\Ib (method Bug9961\HelloWorld::sayHello(), argument) (method Bug9961\HelloWorld::sayHello(), argument))|T of Bug9961\Ic&Bug9961\Id (method Bug9961\HelloWorld::sayHello(), argument)', $a); + assertType('(Bug9961\A&T of Bug9961\Ia&Bug9961\Ib (method Bug9961\HelloWorld::sayHello(), argument))|T of Bug9961\Ic&Bug9961\Id (method Bug9961\HelloWorld::sayHello(), argument)', $a); return $a; } diff --git a/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php b/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php index ba24b227307..7c6bd5fe97e 100644 --- a/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php +++ b/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php @@ -64,7 +64,8 @@ function maybeEmpty(): void if (!empty($foo)) { assertVariableCertainty(TrinaryLogic::createYes(), $foo); } else { - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + // $foo is 1 when defined, so empty($foo) can only be true when it is undefined + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); } @@ -81,9 +82,10 @@ function maybeEmptyUnset(): void unset($foo); assertVariableCertainty(TrinaryLogic::createNo(), $foo); } else { - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + // $foo is 1 when defined, so empty($foo) can only be true when it is undefined + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } diff --git a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index e701bf91f4e..c57d273694a 100644 --- a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php @@ -149,6 +149,7 @@ public function testInstanceof(): void [ 'Instanceof between *NEVER* and ImpossibleInstanceOf\Foo will always evaluate to false.', 234, + $tipText, ], [ 'Instanceof between ImpossibleInstanceOf\Bar&ImpossibleInstanceOf\Foo and ImpossibleInstanceOf\Foo will always evaluate to true.', From 42aaf983543932d4c35d597dab7499ef0b8ec7b6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:05:05 +0200 Subject: [PATCH 306/378] Defer boolean conditional-holder math to the application point The boolean-decomposition holders (`&&` asserted false, `||` asserted true) were built eagerly inside the specify callbacks, reading the ask scope's state for the condition complements, the variable target types and the vacuity checks. SpecifiedTypes now carries a ConditionalExpressionHolderRecipe - the raw condition/holder entries plus the non-variable target types pinned from the operand-walk scope - and MutatingScope::applySpecifiedTypes() evaluates the state-dependent math against the scope the narrowing is applied to. Since every applying path asks and applies on the same scope, this is behavior-preserving; asks that never get applied (rule bridges) stop paying for holder construction entirely. It also removes the last scope-state reads from the holder-building path, a prerequisite for making specify callbacks scope-free. --- phpstan-baseline.neon | 6 + .../ConditionalExpressionHolderRecipe.php | 118 +++++++++++ .../Helper/BooleanNarrowingHelper.php | 30 +-- .../ConditionalExpressionHolderHelper.php | 196 +++++------------- src/Analyser/MutatingScope.php | 13 +- src/Analyser/SpecifiedTypes.php | 37 ++++ 6 files changed, 241 insertions(+), 159 deletions(-) create mode 100644 src/Analyser/ConditionalExpressionHolderRecipe.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8cf37634f54..18510129a98 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,6 +24,12 @@ parameters: count: 3 path: src/Analyser/ExprHandler/AssignHandler.php + - + rawMessage: Casting to string something that's already string. + identifier: cast.useless + count: 3 + path: src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/ConditionalExpressionHolderRecipe.php b/src/Analyser/ConditionalExpressionHolderRecipe.php new file mode 100644 index 00000000000..93a9e7b3a83 --- /dev/null +++ b/src/Analyser/ConditionalExpressionHolderRecipe.php @@ -0,0 +1,118 @@ + $conditionEntries [exprString, expr, fromSureTypes, type] + * @param list $holderEntries [exprString, expr, type, target type pinned at compose time (null = read the applying scope)] + */ + public function __construct( + private array $conditionEntries, + private array $holderEntries, + private bool $holdersFromSureTypes, + ) + { + } + + /** + * @return array + */ + public function evaluate(MutatingScope $scope): array + { + $conditionExpressionTypes = []; + $droppedNoOpConditions = []; + // the unnarrowed type of each condition expression, for the + // dropped-self-condition complement below + $conditionOriginalTypes = []; + foreach ($this->conditionEntries as [$exprString, $expr, $fromSureTypes, $type]) { + $scopeType = $scope->getStateType($expr); + $conditionType = $fromSureTypes + ? TypeCombinator::remove($scopeType, $type) + : TypeCombinator::intersect($scopeType, $type); + if ($scopeType->equals($conditionType)) { + $droppedNoOpConditions[$exprString] = true; + continue; + } + + $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes($expr, $conditionType); + $conditionOriginalTypes[$exprString] = $scopeType; + } + + if ($conditionExpressionTypes === []) { + return []; + } + + $holders = []; + foreach ($this->holderEntries as [$exprString, $expr, $type, $pinnedTargetType]) { + // The target's only link to the antecedent was a no-op relation (e.g. + // `$a === $b`) that got dropped, so the antecedent no longer constrains + // it. Projecting a consequent onto it would fire unsoundly. Skip it. + if (array_key_exists($exprString, $droppedNoOpConditions)) { + continue; + } + + $conditions = $conditionExpressionTypes; + $droppedSelfCondition = null; + if (isset($conditions[$exprString])) { + $droppedSelfCondition = $conditions[$exprString]; + unset($conditions[$exprString]); + } + + if ($conditions === []) { + continue; + } + + $targetType = $pinnedTargetType ?? $scope->getStateType($expr); + $holderType = $this->holdersFromSureTypes + ? TypeCombinator::intersect($targetType, $type) + : TypeCombinator::remove($targetType, $type); + + // The dropped self-condition narrowed the target; without it the + // holder must allow the values it excluded, or it over-narrows when + // only the remaining conditions hold. So union back the complement. + if ($droppedSelfCondition !== null) { + $complement = TypeCombinator::remove($conditionOriginalTypes[$exprString], $droppedSelfCondition->getType()); + if (!$complement instanceof NeverType) { + $holderType = TypeCombinator::union($holderType, $complement); + } + } + + // These boolean-decomposition holders only refine an expression's + // type in a future scope; they must never collapse it to never and + // thereby mark the whole scope unreachable. A never result is an + // artifact (e.g. removing a non-nullable property's full type after + // swapping isset() narrowing), not a real contradiction. + if ($holderType instanceof NeverType && !$targetType instanceof NeverType) { + continue; + } + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes($expr, $holderType), + ); + $holders[$exprString] ??= []; + $holders[$exprString][$holder->getKey()] = $holder; + } + + return $holders; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index 09040e32c2c..a54953effe0 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -10,6 +10,8 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\Type; use PHPStan\DependencyInjection\AutowiredService; +use function array_filter; +use function array_values; use function is_string; /** @@ -76,7 +78,7 @@ public function specifyConjunction( // Condition (antecedent) narrowings: when an arm has no falsey narrowing // (e.g. isset() on an array dim fetch), derive the condition from the truthy // narrowing by swapping sure/sureNot types. This swap is only sound for the - // antecedent — processBooleanConditionalTypes inverts it back to the truthy + // antecedent — the holder-recipe evaluation inverts it back to the truthy // narrowing. It must NOT feed the consequent: inverting a comparison's truthy // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would // over-narrow the consequent (see regression for `$x === $nonConstantString`). @@ -101,12 +103,13 @@ public function specifyConjunction( if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $rightExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, false, true, $s, $leftExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $rightExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, true, true, $s, $leftExpr), - ]))->setRootExpr($rootExpr); + $recipes = [ + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, false, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, false, true, null, $leftExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, true, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, true, true, null, $leftExpr), + ]; + return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($rootExpr); } return $types; @@ -179,12 +182,13 @@ public function specifyDisjunction( if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, false, false, $rightScope, $rightExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, false, false, $s, $leftExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, true, false, $rightScope, $rightExpr), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, true, false, $s, $leftExpr), - ]))->setRootExpr($rootExpr); + $recipes = [ + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, false, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, false, false, null, $leftExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, true, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, true, false, null, $leftExpr), + ]; + return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($rootExpr); } return $types; diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index a93957afb7e..420c0727f51 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -7,23 +7,19 @@ use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\LogicalAnd; use PhpParser\Node\Expr\BinaryOp\LogicalOr; -use PHPStan\Analyser\ConditionalExpressionHolder; -use PHPStan\Analyser\ExpressionTypeHolder; +use PHPStan\Analyser\ConditionalExpressionHolderRecipe; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\NeverType; use PHPStan\Type\TypeCombinator; -use function array_key_exists; -use function count; use function is_string; /** - * Builds the conditional expression holders used to project narrowings of - * boolean operands (`&&`, `||`) into later scopes. Shared by BooleanAndHandler - * and BooleanOrHandler. + * Builds the conditional-expression-holder recipes used to project narrowings + * of boolean operands (`&&`, `||`) into later scopes. Shared by + * BooleanAndHandler and BooleanOrHandler. */ #[AutowiredService] final class ConditionalExpressionHolderHelper @@ -114,172 +110,82 @@ public function augmentDisjunctionTypes( } /** - * Combines several `processBooleanConditionalTypes()` results into one map. + * Captures the raw entries of a boolean-decomposition holder pair as a + * recipe; the state-dependent complement/target math runs against the + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. * - * A plain `array_merge()` would be keyed by the target expression string and - * therefore let a later result overwrite an earlier one targeting the same - * expression, silently dropping a holder. Holders for the same expression are - * unioned by their key instead so all of them survive. + * The condition side asserts that its sub-expression evaluates truthy. + * When that sub-expression is itself a compound boolean (e.g. `$a && $b`), + * the narrowings making it true are spread across both the sure and + * sureNot lists of its specification. All of them are conjuncts of the + * single "this side is true" condition, so they must be gathered together + * into one condition set. Picking only one list would drop a conjunct and + * let the resulting holder fire too eagerly. * - * @param list> $holderLists - * @return array + * @param MutatingScope|null $nonVariableTargetScope the operand-walk scope non-variable + * holder targets were tracked on; their types are pinned from it at compose + * time (null = read every target from the applying scope) */ - public function mergeConditionalHolders(array $holderLists): array + public function buildConditionalHolderRecipe(SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, ?MutatingScope $nonVariableTargetScope, ?Expr $holderSideExpr = null): ?ConditionalExpressionHolderRecipe { - $result = []; - foreach ($holderLists as $holders) { - foreach ($holders as $exprString => $exprHolders) { - foreach ($exprHolders as $key => $holder) { - $result[$exprString][$key] = $holder; - } - } - } - - return $result; - } - - /** - * @return array - */ - public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, MutatingScope $rightScope, ?Expr $holderSideExpr = null): array - { - // The condition side asserts that its sub-expression evaluates truthy. - // When that sub-expression is itself a compound boolean (e.g. `$a && $b`), - // the narrowings making it true are spread across both the sure and - // sureNot lists of its specification. All of them are conjuncts of the - // single "this side is true" condition, so they must be gathered together - // into one condition set. Picking only one list would drop a conjunct and - // let the resulting holder fire too eagerly. // an alternative-form entry (a cross-kind either-branch merge) has no // single condition type; dropping it from the condition set would let // the holder fire too eagerly - build no holders from such a condition if ($conditionSpecifiedTypes->getAlternativeTypes() !== []) { - return []; + return null; } - $conditionExpressionTypes = []; - $droppedNoOpConditions = []; - // the unnarrowed type of each condition expression, pinned at - // holder-build time: the dropped-self-condition complement below must - // not re-ask the scope later, when a different storage may be current - $conditionOriginalTypes = []; + // A holder side that is itself a compound boolean cannot always be split + // into independent per-expression holders. In the `BooleanAnd` false + // context the holder asserts its side is false: when that side is a + // conjunction (`$a && $b`), its negation is the disjunction `!$a || !$b`, + // which has no per-expression narrowing — narrowing each conjunct + // independently would drop a reachable value (e.g. `$a = false, $b = true`). + // Symmetrically, in the `BooleanOr` true context the holder asserts its + // side is true, and a disjunction side (`$a || $b`) is itself a disjunction. + // Such a side is left whole rather than split into over-narrowing holders. + if ($this->isUnsplittableCompoundHolderSide($holderSideExpr, $holderSideIsNegated)) { + return null; + } + + $conditionEntries = []; foreach ($conditionSpecifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; } - $scopeType = $scope->getStateType($expr); - $conditionType = TypeCombinator::remove($scopeType, $type); - if ($scopeType->equals($conditionType)) { - $droppedNoOpConditions[$exprString] = true; - continue; - } - - $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( - $expr, - $conditionType, - ); - $conditionOriginalTypes[$exprString] = $scopeType; + $conditionEntries[] = [(string) $exprString, $expr, true, $type]; } foreach ($conditionSpecifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; } - $scopeType = $scope->getStateType($expr); - $conditionType = TypeCombinator::intersect($scopeType, $type); - if ($scopeType->equals($conditionType)) { - $droppedNoOpConditions[$exprString] = true; - continue; - } - - $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( - $expr, - $conditionType, - ); - $conditionOriginalTypes[$exprString] = $scopeType; + $conditionEntries[] = [(string) $exprString, $expr, false, $type]; } - if (count($conditionExpressionTypes) > 0) { - $holders = []; - $holderTypes = $holdersFromSureTypes ? $holderSpecifiedTypes->getSureTypes() : $holderSpecifiedTypes->getSureNotTypes(); + if ($conditionEntries === []) { + return null; + } - // A holder side that is itself a compound boolean cannot always be split - // into independent per-expression holders. In the `BooleanAnd` false - // context the holder asserts its side is false: when that side is a - // conjunction (`$a && $b`), its negation is the disjunction `!$a || !$b`, - // which has no per-expression narrowing — narrowing each conjunct - // independently would drop a reachable value (e.g. `$a = false, $b = true`). - // Symmetrically, in the `BooleanOr` true context the holder asserts its - // side is true, and a disjunction side (`$a || $b`) is itself a disjunction. - // Such a side is left whole rather than split into over-narrowing holders. - if ($this->isUnsplittableCompoundHolderSide($holderSideExpr, $holderSideIsNegated)) { - return []; + $holderEntries = []; + $holderTypes = $holdersFromSureTypes ? $holderSpecifiedTypes->getSureTypes() : $holderSpecifiedTypes->getSureNotTypes(); + foreach ($holderTypes as $exprString => [$expr, $type]) { + if (!$this->isTrackableExpression($expr)) { + continue; } - foreach ($holderTypes as $exprString => [$expr, $type]) { - if (!$this->isTrackableExpression($expr)) { - continue; - } - - // The target's only link to the antecedent was a no-op relation (e.g. - // `$a === $b`) that got dropped, so the antecedent no longer constrains - // it. Projecting a consequent onto it would fire unsoundly. Skip it. - if (array_key_exists($exprString, $droppedNoOpConditions)) { - continue; - } - - $conditions = $conditionExpressionTypes; - $droppedSelfCondition = null; - foreach ($conditions as $conditionExprString => $condition) { - if ($conditionExprString !== $exprString) { - continue; - } - $droppedSelfCondition = $condition; - unset($conditions[$conditionExprString]); - } - - if (count($conditions) === 0) { - continue; - } - - $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $targetScope->getStateType($expr); - $holderType = $holdersFromSureTypes - ? TypeCombinator::intersect($targetType, $type) - : TypeCombinator::remove($targetType, $type); - - // The dropped self-condition narrowed the target; without it the - // holder must allow the values it excluded, or it over-narrows when - // only the remaining conditions hold. So union back the complement, - // computed from the type pinned when the condition set was built. - if ($droppedSelfCondition !== null) { - $complement = TypeCombinator::remove($conditionOriginalTypes[$exprString], $droppedSelfCondition->getType()); - if (!$complement instanceof NeverType) { - $holderType = TypeCombinator::union($holderType, $complement); - } - } - - // These boolean-decomposition holders only refine an expression's - // type in a future scope; they must never collapse it to never and - // thereby mark the whole scope unreachable. A never result is an - // artifact (e.g. removing a non-nullable property's full type after - // swapping isset() narrowing), not a real contradiction. - if ($holderType instanceof NeverType && !$targetType instanceof NeverType) { - continue; - } - $holder = new ConditionalExpressionHolder( - $conditions, - ExpressionTypeHolder::createYes($expr, $holderType), - ); - $holders[$exprString] ??= []; - $holders[$exprString][$holder->getKey()] = $holder; - } + $pinnedTargetType = !$expr instanceof Expr\Variable && $nonVariableTargetScope !== null + ? $nonVariableTargetScope->getStateType($expr) + : null; + $holderEntries[] = [(string) $exprString, $expr, $type, $pinnedTargetType]; + } - return $holders; + if ($holderEntries === []) { + return null; } - return []; + return new ConditionalExpressionHolderRecipe($conditionEntries, $holderEntries, $holdersFromSureTypes); } /** diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e5448015e32..a1c97e580ea 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3719,6 +3719,17 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); + $newConditionalExpressionHolders = $specifiedTypes->getNewConditionalExpressionHolders(); + foreach ($specifiedTypes->getConditionalExpressionHolderRecipes() as $recipe) { + // the recipes' state-dependent math runs here, against this scope's + // pre-application state - the application point of the narrowing + foreach ($recipe->evaluate($this) as $exprString => $recipeHolders) { + foreach ($recipeHolders as $key => $holder) { + $newConditionalExpressionHolders[$exprString][$key] = $holder; + } + } + } + /** @var static */ return $scope->scopeFactory->create( $scope->context, @@ -3727,7 +3738,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->getNamespace(), $scope->expressionTypes, $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $this->mergeConditionalExpressions($newConditionalExpressionHolders, $scope->conditionalExpressions), $scope->inClosureBindScopeClasses, $scope->anonymousFunctionReflection, $scope->inFirstLevelStatement, diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 29ab4f1b856..6dc27f5d533 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -17,6 +17,14 @@ final class SpecifiedTypes /** @var array */ private array $newConditionalExpressionHolders = []; + /** + * Deferred boolean-decomposition holders, evaluated against the applying + * scope by MutatingScope::applySpecifiedTypes(). + * + * @var list + */ + private array $conditionalExpressionHolderRecipes = []; + private ?Expr $rootExpr = null; /** @@ -67,6 +75,7 @@ public function setAlwaysOverwriteTypes(): self $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; $self->rootExpr = $this->rootExpr; return $self; @@ -81,6 +90,7 @@ public function setRootExpr(?Expr $rootExpr): self $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; $self->rootExpr = $rootExpr; return $self; @@ -95,11 +105,35 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; $self->rootExpr = $this->rootExpr; return $self; } + /** + * @param list $recipes + */ + public function setConditionalExpressionHolderRecipes(array $recipes): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $recipes; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * @return list + */ + public function getConditionalExpressionHolderRecipes(): array + { + return $this->conditionalExpressionHolderRecipes; + } + /** * @api * @return array @@ -137,6 +171,7 @@ public function withAlternativeTypesOf(self $other): self $self->alternativeTypes = $other->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; $self->rootExpr = $this->rootExpr; return $self; @@ -173,6 +208,7 @@ public function removeExpr(string $exprString): self $self->alternativeTypes = $alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; $self->rootExpr = $this->rootExpr; return $self; @@ -342,6 +378,7 @@ public function unionWith(SpecifiedTypes $other): self } } $result->newConditionalExpressionHolders = $conditionalExpressionHolders; + $result->conditionalExpressionHolderRecipes = array_merge($this->conditionalExpressionHolderRecipes, $other->conditionalExpressionHolderRecipes); return $result->setRootExpr($rootExpr); } From d64418cf23333c8da4b43d99b625a1a626f63faf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:15:22 +0200 Subject: [PATCH 307/378] Defer the either-branch union recovery to the application point The disjunction-truthy / conjunction-falsey union recovery (an expression both branch scopes narrow but the exact merge left unconstrained) read the ask scope for its does-it-actually-narrow gates. SpecifiedTypes now carries it as a DeferredSpecifiedTypesAugment - the branch types stay pinned from the operand-walk filtered scopes at compose time - and MutatingScope::applySpecifiedTypes() evaluates the gates against the applying scope and unions the produced entries into the applied batch. --- .../DeferredSpecifiedTypesAugment.php | 17 +++++ .../DisjunctionBranchUnionAugment.php | 62 +++++++++++++++++ .../Helper/BooleanNarrowingHelper.php | 14 ++-- .../ConditionalExpressionHolderHelper.php | 67 +++++++------------ src/Analyser/MutatingScope.php | 16 +++++ src/Analyser/SpecifiedTypes.php | 53 +++++++++++++++ 6 files changed, 182 insertions(+), 47 deletions(-) create mode 100644 src/Analyser/DeferredSpecifiedTypesAugment.php create mode 100644 src/Analyser/DisjunctionBranchUnionAugment.php diff --git a/src/Analyser/DeferredSpecifiedTypesAugment.php b/src/Analyser/DeferredSpecifiedTypesAugment.php new file mode 100644 index 00000000000..f5f97d98afd --- /dev/null +++ b/src/Analyser/DeferredSpecifiedTypesAugment.php @@ -0,0 +1,17 @@ + $candidates [target expr, left branch type, right branch type] + */ + public function __construct( + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private array $candidates, + ) + { + } + + public function evaluate(MutatingScope $scope): ?SpecifiedTypes + { + $result = null; + foreach ($this->candidates as [$targetExpr, $leftType, $rightType]) { + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $originalType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { + continue; + } + + if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + if ($unionType->equals($originalType)) { + continue; + } + + $created = $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope); + $result = $result === null ? $created : $result->unionWith($created); + } + + return $result; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index a54953effe0..4aa107c088d 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -58,7 +58,10 @@ public function specifyConjunction( $types = $leftTypes->unionWith($rightTypes); } else { $types = $leftTypes->intersectWith($rightTypes); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, $leftFalseyScope, $rightFalseyScope, $types); + $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment($nodeScopeResolver, $leftTypes, $rightTypes, $leftFalseyScope, $rightFalseyScope, $types); + if ($branchUnionAugment !== null) { + $types = $types->withDeferredAugment($branchUnionAugment); + } } if ($context->false()) { // Consequent (holder) narrowings projected by each holder: these must be @@ -99,7 +102,7 @@ public function specifyConjunction( $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types); + ))->withAlternativeTypesOf($types)->setDeferredAugments($types->getDeferredAugments()); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } @@ -168,7 +171,10 @@ public function specifyDisjunction( $rightTruthyScope, $rootExpr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, $leftTruthyScope, $rightTruthyScope, $types); + $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment($nodeScopeResolver, $leftTypes, $rightTypes, $leftTruthyScope, $rightTruthyScope, $types); + if ($branchUnionAugment !== null) { + $types = $types->withDeferredAugment($branchUnionAugment); + } } } else { $types = $leftTypes->unionWith($rightTypes); @@ -178,7 +184,7 @@ public function specifyDisjunction( $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types); + ))->withAlternativeTypesOf($types)->setDeferredAugments($types->getDeferredAugments()); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 420c0727f51..db0f35efb85 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -8,12 +8,11 @@ use PhpParser\Node\Expr\BinaryOp\LogicalAnd; use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PHPStan\Analyser\ConditionalExpressionHolderRecipe; +use PHPStan\Analyser\DisjunctionBranchUnionAugment; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\TypeCombinator; use function is_string; /** @@ -31,50 +30,43 @@ public function __construct( { } - public function augmentDisjunctionTypes( + /** + * Captures the either-branch union recovery as a deferred augment: the + * branch types are read from the operand-walk filtered scopes here at + * compose time, while the does-it-actually-narrow gates run against the + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. + */ + public function buildBranchUnionAugment( NodeScopeResolver $nodeScopeResolver, - MutatingScope $scope, - SpecifiedTypes $leftNormalized, - SpecifiedTypes $rightNormalized, + SpecifiedTypes $leftTypes, + SpecifiedTypes $rightTypes, MutatingScope $leftFilteredScope, MutatingScope $rightFilteredScope, SpecifiedTypes $types, - ): SpecifiedTypes + ): ?DisjunctionBranchUnionAugment { $candidateExprs = []; - foreach ($leftNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($leftTypes->getSureTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } - foreach ($rightNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($rightTypes->getSureTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } $existingSureTypes = $types->getSureTypes(); $existingAlternativeTypes = $types->getAlternativeTypes(); - $viableCandidates = []; + $candidates = []; foreach ($candidateExprs as $exprString => $targetExpr) { if (isset($existingSureTypes[$exprString])) { continue; } // the exact either-branch merge already constrains this expression // (an alternative-form entry) - the branch-scope union recovery - // below is the old lossy-merge compensation and would only add a - // weaker entry on top + // would only add a weaker entry on top if (isset($existingAlternativeTypes[$exprString])) { continue; } - if (!$scope->hasExpressionType($targetExpr)->yes()) { - continue; - } - $viableCandidates[$exprString] = $targetExpr; - } - - if ($viableCandidates === []) { - return $types; - } - - foreach ($viableCandidates as $targetExpr) { if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) { continue; } @@ -84,29 +76,18 @@ public function augmentDisjunctionTypes( // the operands were processed during processExpr; read their stored // results on these filtered scopes instead of re-walking via getType(). - $originalType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); - $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftFilteredScope); - $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightFilteredScope); - - if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { - continue; - } - - if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) { - continue; - } - - $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($originalType)) { - continue; - } + $candidates[] = [ + $targetExpr, + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftFilteredScope), + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightFilteredScope), + ]; + } - $types = $types->unionWith( - $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), - ); + if ($candidates === []) { + return null; } - return $types; + return new DisjunctionBranchUnionAugment($nodeScopeResolver, $this->defaultNarrowingHelper, $candidates); } /** diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a1c97e580ea..fc2063098b0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3546,6 +3546,22 @@ public function filterByFalseyValue(Expr $expr): self */ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { + // deferred augments see this scope's pre-application state - the + // application point of the narrowing; their entries join this batch + $pendingAugments = $specifiedTypes->getDeferredAugments(); + while ($pendingAugments !== []) { + $augment = array_shift($pendingAugments); + $augmentTypes = $augment->evaluate($this); + if ($augmentTypes === null) { + continue; + } + + foreach ($augmentTypes->getDeferredAugments() as $nestedAugment) { + $pendingAugments[] = $nestedAugment; + } + $specifiedTypes = $specifiedTypes->unionWith($augmentTypes); + } + $typeSpecifications = []; foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 6dc27f5d533..35d1e1baaad 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -25,6 +25,15 @@ final class SpecifiedTypes */ private array $conditionalExpressionHolderRecipes = []; + /** + * State-dependent augmentations evaluated against the applying scope by + * MutatingScope::applySpecifiedTypes(); their entries join the applied + * batch. + * + * @var list + */ + private array $deferredAugments = []; + private ?Expr $rootExpr = null; /** @@ -76,6 +85,7 @@ public function setAlwaysOverwriteTypes(): self $self->overwrite = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $this->rootExpr; return $self; @@ -91,6 +101,7 @@ public function setRootExpr(?Expr $rootExpr): self $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $rootExpr; return $self; @@ -106,6 +117,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $this->rootExpr; return $self; @@ -121,6 +133,7 @@ public function setConditionalExpressionHolderRecipes(array $recipes): self $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $recipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $this->rootExpr; return $self; @@ -134,6 +147,43 @@ public function getConditionalExpressionHolderRecipes(): array return $this->conditionalExpressionHolderRecipes; } + public function withDeferredAugment(DeferredSpecifiedTypesAugment $augment): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = [...$this->deferredAugments, $augment]; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * @param list $augments + */ + public function setDeferredAugments(array $augments): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $augments; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * @return list + */ + public function getDeferredAugments(): array + { + return $this->deferredAugments; + } + /** * @api * @return array @@ -172,6 +222,7 @@ public function withAlternativeTypesOf(self $other): self $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $this->rootExpr; return $self; @@ -209,6 +260,7 @@ public function removeExpr(string $exprString): self $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self->deferredAugments = $this->deferredAugments; $self->rootExpr = $this->rootExpr; return $self; @@ -379,6 +431,7 @@ public function unionWith(SpecifiedTypes $other): self } $result->newConditionalExpressionHolders = $conditionalExpressionHolders; $result->conditionalExpressionHolderRecipes = array_merge($this->conditionalExpressionHolderRecipes, $other->conditionalExpressionHolderRecipes); + $result->deferredAugments = array_merge($this->deferredAugments, $other->deferredAugments); return $result->setRootExpr($rootExpr); } From a6fd2a1372fb1fe52b6857e874c16a2a7baff055 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:18:58 +0200 Subject: [PATCH 308/378] Defer the disjunction holder projection to the application point The disjunction-truthy projection of conditional-holder narrowings discovered its candidates in the ask scope's registered holders and gated them against ask-scope state. It is now a DeferredSpecifiedTypesAugment carrying the operand-walk truthy scopes; candidate discovery and the gates run against the applying scope in MutatingScope::applySpecifiedTypes(). With this and the two previous steps, the boolean narrowing helpers' ask-scope parameter feeds only the child-result recursion and the disjunction's decided-operand guards - all holder building and branch-union recovery is symbolic until application. --- .../DisjunctionHolderProjectionAugment.php | 95 +++++++++++++++++++ .../Helper/BooleanNarrowingHelper.php | 82 ++-------------- 2 files changed, 104 insertions(+), 73 deletions(-) create mode 100644 src/Analyser/DisjunctionHolderProjectionAugment.php diff --git a/src/Analyser/DisjunctionHolderProjectionAugment.php b/src/Analyser/DisjunctionHolderProjectionAugment.php new file mode 100644 index 00000000000..980345de33c --- /dev/null +++ b/src/Analyser/DisjunctionHolderProjectionAugment.php @@ -0,0 +1,95 @@ + $alternativeKeys expressions the exact either-branch + * merge already constrains - the weaker branch-scope union must not + * be added on top + */ + public function __construct( + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private MutatingScope $leftTruthyScope, + private MutatingScope $leftFalseyScope, + private MutatingScope $rightTruthyScope, + private array $alternativeKeys, + ) + { + } + + public function evaluate(MutatingScope $scope): ?SpecifiedTypes + { + $result = null; + $seen = []; + foreach ([$scope, $this->leftFalseyScope] as $sourceScope) { + foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) { + if (isset($seen[$rootExprString])) { + continue; + } + if ($holders === []) { + continue; + } + $seen[$rootExprString] = true; + $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); + + if (isset($this->alternativeKeys[$rootExprString])) { + continue; + } + + // Only project when the target stays Yes-defined in the original + // scope and in both filtered branches. A sure type implicitly + // raises certainty to Yes, which would wrongly upgrade Maybe-defined + // variables — `if (empty($a['bar']))` for instance leaves `$a` + // Maybe-defined because `empty()` tolerates undefined offsets. + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$this->leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$this->rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $origType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + $leftType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $this->leftTruthyScope); + $rightType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $this->rightTruthyScope); + + $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); + $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); + + if (!$leftNarrowed || !$rightNarrowed) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + if ($unionType->equals($origType)) { + continue; + } + + $created = $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()); + $result = $result === null ? $created : $result->unionWith($created); + } + } + + return $result; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index 4aa107c088d..e6d4d252dc9 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -3,11 +3,11 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use PhpParser\Node\Expr; +use PHPStan\Analyser\DisjunctionHolderProjectionAugment; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; -use PHPStan\Type\TypeCombinator; use PHPStan\Type\Type; use PHPStan\DependencyInjection\AutowiredService; use function array_filter; @@ -163,14 +163,18 @@ public function specifyDisjunction( $types = $leftTypes; } else { $types = $leftTypes->intersectWith($rightTypes); - $types = $this->augmentDisjunctionTruthyWithConditionalHolders( + $alternativeKeys = []; + foreach ($types->getAlternativeTypes() as $exprString => $entry) { + $alternativeKeys[$exprString] = true; + } + $types = $types->withDeferredAugment(new DisjunctionHolderProjectionAugment( $nodeScopeResolver, - $s, + $this->defaultNarrowingHelper, $leftTruthyScope, $rightScope, $rightTruthyScope, - $rootExpr, - $types); + $alternativeKeys, + )); $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment($nodeScopeResolver, $leftTypes, $rightTypes, $leftTruthyScope, $rightTruthyScope, $types); if ($branchUnionAugment !== null) { $types = $types->withDeferredAugment($branchUnionAugment); @@ -200,74 +204,6 @@ public function specifyDisjunction( return $types; } - private function augmentDisjunctionTruthyWithConditionalHolders( - NodeScopeResolver $nodeScopeResolver, - MutatingScope $scope, - MutatingScope $leftTruthyScope, - MutatingScope $rightScope, - MutatingScope $rightTruthyScope, - Expr $rootExpr, - SpecifiedTypes $types, - ): SpecifiedTypes - { - $seen = []; - foreach ([$scope, $rightScope] as $sourceScope) { - foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) { - if (isset($seen[$rootExprString])) { - continue; - } - if ($holders === []) { - continue; - } - $seen[$rootExprString] = true; - $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); - - // the exact either-branch merge already constrains this - // expression - do not add the weaker branch-scope union on top - if (isset($types->getAlternativeTypes()[$rootExprString])) { - continue; - } - - // Only project when the target stays Yes-defined in the original - // scope and in both filtered branches. A sure type implicitly - // raises certainty to Yes, which would wrongly upgrade Maybe-defined - // variables — `if (empty($a['bar']))` for instance leaves `$a` - // Maybe-defined because `empty()` tolerates undefined offsets. - if (!$scope->hasExpressionType($targetExpr)->yes()) { - continue; - } - if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - - $origType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); - $leftType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); - $rightType = $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); - - $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); - $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); - - if (!$leftNarrowed || !$rightNarrowed) { - continue; - } - - $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($origType)) { - continue; - } - - $types = $types->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()), - ); - } - } - - return $types; - } - private function allExpressionsTrackable(SpecifiedTypes $types): bool { // an alternative-form entry has no single condition type to track From e1e21d190c6ea9518f67f6d70782bc562c97549b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:32:15 +0200 Subject: [PATCH 309/378] Compute disjunction decided-operand verdicts at the evaluation point The decided checks in specifyDisjunction (left decidedly false - use the right narrowing only; left decidedly true or right decidedly false - use the left) read the operands' types on the asking scope. They now read the operands' walk-position types - the results' own evaluation points, matching the || handler's own typeCallback - and take only the asked flavour bit. Affects the verdict callbacks of BooleanOrHandler, TernaryHandler's decomposition and EmptyHandler's isset/truthiness disjuncts. --- src/Analyser/ExprHandler/BooleanOrHandler.php | 4 ++-- src/Analyser/ExprHandler/EmptyHandler.php | 11 ++++++----- .../Helper/BooleanNarrowingHelper.php | 14 +++++++++----- src/Analyser/ExprHandler/TernaryHandler.php | 16 ++++++++-------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 209efa3f6f4..0759e3ad567 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -124,12 +124,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $expr->left, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), - static fn (MutatingScope $scope): Type => $leftResult->getTypeOnScope($scope, $scope->nativeTypesPromoted), + static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(), $leftResult->getTruthyScope(), $leftResult->getFalseyScope(), $expr->right, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), - static fn (MutatingScope $scope): Type => $rightResult->getTypeOnScope($scope, $scope->nativeTypesPromoted), + static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(), $rightResult->getTruthyScope(), ); }, diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 8dd13577071..2b70a1ee7ec 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -75,7 +75,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope): SpecifiedTypes { $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); @@ -100,8 +100,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $expr->expr, $readType, $issetNode, $negated); }; - $leftType = static function (MutatingScope $scope) use ($exprResult): Type { - $result = $exprResult->getIssetabilityResolution($scope, false)->isSet(static function (Type $type): ?bool { + $leftType = static function (bool $nativeTypesPromoted) use ($exprResult, $beforeScope): Type { + $issetabilityScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $result = $exprResult->getIssetabilityResolution($issetabilityScope, false)->isSet(static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; @@ -122,8 +123,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $exprResult->getSpecifiedTypesForScope($scope, $ctx->negate()); }; - $rightType = static function (MutatingScope $scope) use ($exprResult): Type { - $bool = $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted)->toBoolean(); + $rightType = static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $bool = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); if ($bool->isTrue()->yes()) { return new ConstantBooleanType(false); } diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index e6d4d252dc9..34108ca6473 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -125,10 +125,14 @@ public function specifyConjunction( * (the non-null narrowing of empty()) reuse it without synthesizing * BooleanOr chains. * + * The operand verdict callbacks take only the asked flavour: the decided + * checks read the operands' walk-position types (the results' own + * evaluation points), never the asking scope. + * * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $leftTypesCallback - * @param callable(MutatingScope): Type $leftTypeCallback + * @param callable(bool): Type $leftTypeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $rightTypesCallback - * @param callable(MutatingScope): Type $rightTypeCallback + * @param callable(bool): Type $rightTypeCallback */ public function specifyDisjunction( NodeScopeResolver $nodeScopeResolver, @@ -153,12 +157,12 @@ public function specifyDisjunction( if ($context->true()) { if ( - $leftTypeCallback($s)->toBoolean()->isFalse()->yes() + $leftTypeCallback($s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() ) { $types = $rightTypes; } elseif ( - $leftTypeCallback($s)->toBoolean()->isTrue()->yes() - || $rightTypeCallback($s)->toBoolean()->isFalse()->yes() + $leftTypeCallback($s->nativeTypesPromoted)->toBoolean()->isTrue()->yes() + || $rightTypeCallback($s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() ) { $types = $leftTypes; } else { diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index cf4244256ac..5401f71f07b 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -179,10 +179,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $notCondNode = new Expr\BooleanNot($expr->cond); $condTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx); - $condType = static fn (MutatingScope $scope): Type => $ternaryCondResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $condType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType(); $notCondTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx->negate()); - $notCondType = static function (MutatingScope $scope) use ($ternaryCondResult): Type { - $bool = $ternaryCondResult->getTypeOnScope($scope, $scope->nativeTypesPromoted)->toBoolean(); + $notCondType = static function (bool $nativeTypesPromoted) use ($ternaryCondResult): Type { + $bool = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); if ($bool->isTrue()->yes()) { return new ConstantBooleanType(false); } @@ -193,9 +193,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }; $andVerdict = static function (callable $left, callable $right): callable { - return static function (MutatingScope $scope) use ($left, $right): Type { - $leftBool = $left($scope)->toBoolean(); - $rightBool = $right($scope)->toBoolean(); + return static function (bool $nativeTypesPromoted) use ($left, $right): Type { + $leftBool = $left($nativeTypesPromoted)->toBoolean(); + $rightBool = $right($nativeTypesPromoted)->toBoolean(); if ($leftBool->isFalse()->yes() || $rightBool->isFalse()->yes()) { return new ConstantBooleanType(false); } @@ -207,7 +207,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }; }; $elseTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $elseResult->getSpecifiedTypesForScope($scope, $ctx); - $elseType = static fn (MutatingScope $scope): Type => $elseResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $elseType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType(); $condTruthyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createTruthy())); $condFalseyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createFalsey())); @@ -235,7 +235,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // left disjunct: cond && if $aNode = new BooleanAnd($expr->cond, $expr->if); $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); - $ifType = static fn (MutatingScope $scope): Type => $ifResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $ifType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType(); $ifFalseyOnCondTruthyScope = $condTruthyScope->applySpecifiedTypes($ifTypes($condTruthyScope, TypeSpecifierContext::createFalsey())); $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, From c330e9694326f3b371965957d6be1c60f773a3e1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:35:36 +0200 Subject: [PATCH 310/378] Compute comparison, coalesce and nullsafe-twin verdicts at the evaluation point Same move as the disjunction decided guards: the comparison's own verdict in BinaryOpHandler (previously read from ask-scope storage or tracked state with a callback fallback), the coalesce's right-side-decidedly-false checks in both its specify and create callbacks, and createSubjectTypes' nullsafe short-circuit null-ruled-out check now read the walk results' own memoized types; only the asked flavour bit follows the asking scope. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 16 +++++----------- src/Analyser/ExprHandler/CoalesceHandler.php | 4 ++-- .../Helper/DefaultNarrowingHelper.php | 4 ++-- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 544c0ad6a42..54f0b50c7cd 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -275,17 +275,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $leftArgResult, $rightArgResult, - // the comparison's own verdict, in Identical semantics - static function () use ($nodeScopeResolver, $expr, $scope, $typeCallback): Type { - // on storage misses (loop-convergence passes) compute the - // verdict from the captured operand results instead of - // re-walking the comparison on demand - $ownType = $nodeScopeResolver->findStoredResult($expr, $scope)?->getTypeOnScope($scope, $scope->nativeTypesPromoted); - if ($ownType === null) { - $ownType = $scope->hasExpressionType($expr)->yes() - ? $scope->getTrackedExpressionType($expr) - : $typeCallback($scope->nativeTypesPromoted); - } + // the comparison's own verdict, in Identical semantics - + // computed from the captured operand results (the walk's + // evaluation point), only the flavour follows the ask + static function () use ($expr, $scope, $typeCallback): Type { + $ownType = $typeCallback($scope->nativeTypesPromoted); if ($expr instanceof BinaryOp\NotIdentical) { if ($ownType->isTrue()->yes()) { return new ConstantBooleanType(false); diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 01fe9e2544a..b09a16d1df3 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -111,7 +111,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $expr->left, $condResult, $expr, $context); } - if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeOnScope($s, $s->nativeTypesPromoted)->toBoolean())->yes()) { + if ((new ConstantBooleanType(false))->isSuperTypeOf(($s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); } @@ -125,7 +125,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // TypeSpecifier::create() recovered by unwrapping the coalesce createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { if (!$context->null()) { - $rightType = $rightResult->getTypeOnScope($s, $s->nativeTypesPromoted); + $rightType = $s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); if ( ($context->true() && $type->isSuperTypeOf($rightType)->no()) || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index a7d6a20c66d..335ec317486 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -200,9 +200,9 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR // memoized result type replace the old scope-type probe if ($subjectResult->containsNullsafe()) { if ($context->true()) { - $nullRuledOut = $type->isNull()->no() || $subjectResult->getTypeOnScope($s, $s->nativeTypesPromoted)->isNull()->no(); + $nullRuledOut = $type->isNull()->no() || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); } elseif ($context->false()) { - $nullRuledOut = TypeCombinator::containsNull($type) || $subjectResult->getTypeOnScope($s, $s->nativeTypesPromoted)->isNull()->no(); + $nullRuledOut = TypeCombinator::containsNull($type) || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); } else { $nullRuledOut = false; } From 0afee6b063f8f3d3ae1978aeb4d59ded197728fc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:39:40 +0200 Subject: [PATCH 311/378] Resolve isset/coalesce issetability verdicts on the evaluation scope The issetability gates of empty() narrowing and the coalesce falsey narrowing read the asking scope; they now run on the handler's captured beforeScope (flavour-mapped by the asked bit), like the corresponding typeCallbacks already did. CoalesceCompositionHelper's getFalseySpecifiedTypes takes an explicit evaluation scope; the asking scope stays only as the conduit for the left side's narrowing fan-out. --- src/Analyser/ExprHandler/CoalesceHandler.php | 6 +++--- src/Analyser/ExprHandler/EmptyHandler.php | 2 +- .../ExprHandler/Helper/CoalesceCompositionHelper.php | 9 +++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index b09a16d1df3..a31e5bce51c 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the falsey narrowing of this very node - asking the scope about it // mid-processing would take the on-demand path and recurse - $rightScope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey())); + $rightScope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); // the left-is-set narrowing, composed from the already-processed chain // results - the inside-out equivalent of narrowing by isset($expr->left) @@ -102,13 +102,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nativeTypesPromoted, ), - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } if (!$context->true()) { - return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $expr->left, $condResult, $expr, $context); + return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr->left, $condResult, $expr, $context); } if ((new ConstantBooleanType(false))->isSuperTypeOf(($s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes()) { diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 2b70a1ee7ec..036a6bfe755 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -76,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope): SpecifiedTypes { - $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + $isset = $exprResult->getIssetabilityResolution($s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); } diff --git a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php index b90ebc345a6..4189d4d9f74 100644 --- a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php +++ b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php @@ -32,9 +32,14 @@ public function __construct( /** * A falsey coalesce means its left side was null (when it was surely set). */ - public function getFalseySpecifiedTypes(MutatingScope $s, Expr $leftExpr, ExpressionResult $leftResult, Expr $rootExpr, TypeSpecifierContext $context): SpecifiedTypes + /** + * The issetability verdict runs on the evaluation scope (where the left + * side was walked); the asking scope is only the conduit for the left + * side's narrowing fan-out. + */ + public function getFalseySpecifiedTypes(MutatingScope $s, MutatingScope $evaluationScope, Expr $leftExpr, ExpressionResult $leftResult, Expr $rootExpr, TypeSpecifierContext $context): SpecifiedTypes { - $isset = $leftResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + $isset = $leftResult->getIssetabilityResolution($evaluationScope, false)->isSet(static fn (): bool => true); if ($isset !== true) { return new SpecifiedTypes(); From 004709939618acea1ec6a8bfaacf251168228d0a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 21:53:16 +0200 Subject: [PATCH 312/378] Derive decomposition branch scopes from the evaluation point, not per ask The isset/empty/ternary decompositions rebuilt their branch scopes from the asking scope on every specify ask. The ternary now reuses the operand walks' own memoized truthy/falsey scopes (the cond/if/else results); empty() and multi-subject isset() derive their fold scopes from the handler's beforeScope once, lazily, and reuse them across asks - isset() additionally memoizes the whole accumulated conjunction closure, which is ask-independent after this change. --- src/Analyser/ExprHandler/EmptyHandler.php | 21 +++++++++++++++---- src/Analyser/ExprHandler/IssetHandler.php | 20 ++++++++++++++---- src/Analyser/ExprHandler/TernaryHandler.php | 23 ++++++++++++++------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 036a6bfe755..e5e4d002028 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -59,6 +59,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new EmptyExpressionNode($expr, $exprResult), $beforeScope, $storage, $context); + // lazily memoized branch scopes of the !isset($x) || !$x decomposition + /** @var array{MutatingScope, MutatingScope, MutatingScope}|null $foldScopes */ + $foldScopes = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -75,7 +79,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope, &$foldScopes): SpecifiedTypes { $isset = $exprResult->getIssetabilityResolution($s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); @@ -135,9 +139,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }; - $leftTruthyScope = $s->applySpecifiedTypes($leftTypes($s, TypeSpecifierContext::createTruthy())); - $leftFalseyScope = $s->applySpecifiedTypes($leftTypes($s, TypeSpecifierContext::createFalsey())); - $rightTruthyScope = $leftFalseyScope->applySpecifiedTypes($rightTypes($leftFalseyScope, TypeSpecifierContext::createTruthy())); + // the disjuncts' branch scopes derive from the evaluation point, + // not the asking scope - computed once, reused across asks + if ($foldScopes === null) { + $leftTruthyScope = $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createTruthy())); + $leftFalseyScope = $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + $foldScopes = [ + $leftTruthyScope, + $leftFalseyScope, + $leftFalseyScope->applySpecifiedTypes($rightTypes($leftFalseyScope, TypeSpecifierContext::createTruthy())), + ]; + } + [$leftTruthyScope, $leftFalseyScope, $rightTruthyScope] = $foldScopes; return $this->booleanNarrowingHelper->specifyDisjunction( $nodeScopeResolver, diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 8fc0959b5a0..fbe1d40372f 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -132,6 +132,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); + // lazily memoized multi-subject conjunction fold (ask-independent) + $foldAccTypes = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -168,7 +171,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType($issetResult); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $beforeScope, &$foldAccTypes): SpecifiedTypes { // type of an already-processed chain link, read from its captured // result (re-evaluated on the asking scope, honouring narrowing) - // never re-walked through the scope @@ -211,10 +214,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }; }; + // the fold's branch scopes derive from the evaluation point, + // not the asking scope - the accumulated conjunction closure + // is ask-independent and built once, reused across asks + if ($foldAccTypes !== null) { + return $foldAccTypes($s, $context)->setRootExpr($expr); + } + $accExpr = new Isset_([$expr->vars[0]], $expr->getAttributes()); $accTypes = $makeSubjectTypes($expr->vars[0], $varResults[0]); - $accTruthyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createTruthy())); - $accFalseyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createFalsey())); + $accTruthyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createFalsey())); for ($i = 1, $varCount = count($expr->vars); $i < $varCount; $i++) { $rightExprNode = new Isset_([$expr->vars[$i]], $expr->getAttributes()); @@ -240,9 +250,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); - $accFalseyScope = $s->applySpecifiedTypes($accTypes($s, TypeSpecifierContext::createFalsey())); + $accFalseyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createFalsey())); } + $foldAccTypes = $accTypes; + return $accTypes($s, $context)->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 5401f71f07b..d1ed9460556 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -119,6 +119,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + // lazily memoized merged-falsey scope of the (cond && if) disjunct + $aFalseyScope = null; + return $this->expressionResultFactory->create( $finalScope, beforeScope: $scope, @@ -168,7 +171,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseType, ); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { if ($expr->cond instanceof Ternary || $context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } @@ -209,12 +212,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $elseResult->getSpecifiedTypesForScope($scope, $ctx); $elseType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType(); - $condTruthyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createTruthy())); - $condFalseyScope = $s->applySpecifiedTypes($condTypes($s, TypeSpecifierContext::createFalsey())); + // the decomposition's branch scopes are the operand walks' own + // memoized branch scopes (the evaluation points), not ask-derived + $condTruthyScope = $ternaryCondResult->getTruthyScope(); + $condFalseyScope = $ternaryCondResult->getFalseyScope(); // right disjunct: !cond && else $bNode = new BooleanAnd($notCondNode, $expr->else); - $elseFalseyOnCondFalseyScope = $condFalseyScope->applySpecifiedTypes($elseTypes($condFalseyScope, TypeSpecifierContext::createFalsey())); + $elseFalseyOnCondFalseyScope = $elseResult->getFalseyScope(); $bTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $scope, @@ -229,14 +234,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseFalseyOnCondFalseyScope, ); $bType = $andVerdict($notCondType, $elseType); - $bTruthyScope = $s->applySpecifiedTypes($bTypes($s, TypeSpecifierContext::createTruthy())); + $bTruthyScope = $elseResult->getTruthyScope(); if ($ifResult !== null && $expr->if !== null) { // left disjunct: cond && if $aNode = new BooleanAnd($expr->cond, $expr->if); $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); $ifType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType(); - $ifFalseyOnCondTruthyScope = $condTruthyScope->applySpecifiedTypes($ifTypes($condTruthyScope, TypeSpecifierContext::createFalsey())); + $ifFalseyOnCondTruthyScope = $ifResult->getFalseyScope(); $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $scope, @@ -251,8 +256,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseyOnCondTruthyScope, ); $aType = $andVerdict($condType, $ifType); - $aTruthyScope = $s->applySpecifiedTypes($aTypes($s, TypeSpecifierContext::createTruthy())); - $aFalseyScope = $s->applySpecifiedTypes($aTypes($s, TypeSpecifierContext::createFalsey())); + $aTruthyScope = $ifResult->getTruthyScope(); + // the merged falsey of (cond && if) has no single walk scope - + // derived from the evaluation point once, reused across asks + $aFalseyScope ??= $scope->applySpecifiedTypes($aTypes($scope, TypeSpecifierContext::createFalsey())); return $this->booleanNarrowingHelper->specifyDisjunction( $nodeScopeResolver, From 994e7208c2fdb8830f541c0ee2a2017431db3de6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 22:11:40 +0200 Subject: [PATCH 313/378] Compose nullsafe narrowing from captured results instead of walking a synthetic `$x?->prop` / `$x?->m()` narrowing walked a synthetic BooleanAnd(NotIdentical($x, null), plainTwin) on the asking scope per ask. Both handlers now compose it through specifyConjunction: the receiver-not-null side is the captured receiver result narrowed by NullType in the negated context (the equality null slice), the plain twin side is the already-captured fetch/call result, the left-truthy scope is the ensured-non-null scope the twin was walked on, and the receiver-is-null branch scope derives lazily from beforeScope. The fabricated NotIdentical is only printed into holder keys, never walked. --- .../ExprHandler/NullsafeMethodCallHandler.php | 43 ++++++++++++++++--- .../NullsafePropertyFetchHandler.php | 43 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 9d2ab8c55f3..cbd1b9b1046 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; @@ -40,6 +41,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -112,6 +114,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); }; + // the receiver's stored result, for composing the receiver-not-null + // narrowing without re-walking the chain + $receiverResult = $storage->findExpressionResult($expr->var); + // lazily memoized receiver-is-null branch scope of the decomposition + $leftFalseyScope = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -122,18 +130,41 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - $types = $this->defaultNarrowingHelper->specifyTypesForNode( + // `$x?->...` narrows like ($x !== null) && $x->..., composed from + // the captured receiver and plain-twin results - the fabricated + // NotIdentical is only printed into holder keys, never walked + $notIdenticalNode = new NotIdentical($expr->var, new ConstFetch(new Name('null'))); + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($expr, $receiverResult, $notIdenticalNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIdenticalNode, $ctx); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $receiverResult, new NullType(), $ctx->negate()); + }; + $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); + + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives lazily + $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + + $types = $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, $s, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - $methodCall, - ), $context, + $expr, + $notIdenticalNode, + $leftTypes, + $nonNullabilityResult->getScope(), + $leftFalseyScope, + $methodCall, + $rightTypes, + $exprResult->getFalseyScope(), )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index e76b0e29135..446eaebe952 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; @@ -40,6 +41,7 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -92,6 +94,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); }; + // the receiver's stored result, for composing the receiver-not-null + // narrowing without re-walking the chain + $receiverResult = $storage->findExpressionResult($expr->var); + // lazily memoized receiver-is-null branch scope of the decomposition + $leftFalseyScope = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -102,18 +110,41 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch): SpecifiedTypes { + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } - $types = $this->defaultNarrowingHelper->specifyTypesForNode( + // `$x?->...` narrows like ($x !== null) && $x->..., composed from + // the captured receiver and plain-twin results - the fabricated + // NotIdentical is only printed into holder keys, never walked + $notIdenticalNode = new NotIdentical($expr->var, new ConstFetch(new Name('null'))); + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($expr, $receiverResult, $notIdenticalNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIdenticalNode, $ctx); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $receiverResult, new NullType(), $ctx->negate()); + }; + $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); + + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives lazily + $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + + $types = $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, $s, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - $propertyFetch, - ), $context, + $expr, + $notIdenticalNode, + $leftTypes, + $nonNullabilityResult->getScope(), + $leftFalseyScope, + $propertyFetch, + $rightTypes, + $exprResult->getFalseyScope(), )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); From 14a0ac8a487949389fee4f000d9162dacd35139f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 22:15:48 +0200 Subject: [PATCH 314/378] Read isset chain-link types on the evaluation point IssetHandler's top-level chain reader and single/multi-subject chain narrowing builders received the asking scope for their state reads (chain-link re-evaluation, hasVariableType gates, issetability); they now run on the handler's flavour-mapped beforeScope. The decomposition fold conduits keep their given scopes, which are evaluation-point derived since the fold bake. --- src/Analyser/ExprHandler/IssetHandler.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index fbe1d40372f..56c6b1bdae8 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -173,9 +173,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }, specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $beforeScope, &$foldAccTypes): SpecifiedTypes { // type of an already-processed chain link, read from its captured - // result (re-evaluated on the asking scope, honouring narrowing) - - // never re-walked through the scope - $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $s, $nodeScopeResolver); + // result on the evaluation point (only the flavour follows the + // asking scope) - never re-walked through the scope + $evaluationScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope, $nodeScopeResolver); if (count($expr->vars) === 0 || $context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); @@ -189,7 +190,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $types = new SpecifiedTypes(); foreach ($expr->vars as $var) { $types = $types->unionWith( - $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $var, $readType, $expr, $context), + $this->defaultNarrowingHelper->createIssetTruthyChainTypes($evaluationScope, $var, $readType, $expr, $context), ); } @@ -261,10 +262,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $issetExpr = $expr->vars[0]; if (!$context->true()) { - return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($s, $issetExpr, $varResults[0], $readType, $context, $expr); + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($evaluationScope, $issetExpr, $varResults[0], $readType, $context, $expr); } - return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($s, $issetExpr, $readType, $expr, $context); + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($evaluationScope, $issetExpr, $readType, $expr, $context); }, ); } From ab9358c4c4facc72c5abbfff49be78e9b6413432 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 22:25:42 +0200 Subject: [PATCH 315/378] Memoize specify results per (context, flavour) at the evaluation point ExpressionResult::getSpecifiedTypes(TypeSpecifierContext, bool) computes the narrowing once on the flavour-mapped beforeScope and memoizes it; getSpecifiedTypesForScope() reduces every asking scope to its flavour bit and delegates. This is what the symbolic SpecifiedTypes work was for: alternative-form terms, conditional-holder recipes and deferred augments carry the state-dependent math to applySpecifiedTypes(), so a single memoized SpecifiedTypes serves every asking position - rule bridges, boolean compositions and branch derivations alike. --- src/Analyser/ExpressionResult.php | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 439b436c171..aee6bf381cd 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -11,6 +11,7 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; +use function spl_object_id; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult @@ -25,6 +26,9 @@ final class ExpressionResult /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ private $createTypesCallback; + /** @var array */ + private array $specifiedTypes = []; + private ?MutatingScope $truthyScope = null; private ?MutatingScope $falseyScope = null; @@ -165,7 +169,7 @@ public function getTruthyScope(): MutatingScope } return $this->truthyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), + $this->getSpecifiedTypes(TypeSpecifierContext::createTruthy(), $this->scope->nativeTypesPromoted), ); } @@ -182,7 +186,7 @@ public function getFalseyScope(): MutatingScope } return $this->falseyScope = $this->scope->applySpecifiedTypes( - ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), + $this->getSpecifiedTypes(TypeSpecifierContext::createFalsey(), $this->scope->nativeTypesPromoted), ); } @@ -336,7 +340,26 @@ public function canResolveOwnType(): bool /** Evaluates this expression's narrowing on the given scope. */ public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes { - return ($this->specifyTypesCallback)($scope, $context); + return $this->getSpecifiedTypes($context, $scope->nativeTypesPromoted); + } + + /** + * The expression's narrowing for the given context, computed at its own + * evaluation point (the flavour-mapped beforeScope) and memoized per + * (context, flavour). All state-dependent math lives in the symbolic + * SpecifiedTypes (alternative terms, holder recipes, deferred augments) + * and is evaluated by applySpecifiedTypes() against whichever scope the + * narrowing is applied to - so one memoized SpecifiedTypes serves every + * asking position. + */ + public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTypesPromoted = false): SpecifiedTypes + { + $key = (spl_object_id($context) << 1) | ($nativeTypesPromoted ? 1 : 0); + + return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)( + $nativeTypesPromoted ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope, + $context, + ); } /** From f203fc3edb7c91feec4ed072169f52818dab1176 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 23:24:48 +0200 Subject: [PATCH 316/378] Flip specifyTypesCallback to (TypeSpecifierContext, bool) The scope parameter carried no information beyond its flavour bit since the per-context memoization - callbacks were only ever invoked with the flavour-mapped beforeScope. The signature now says so: callable(TypeSpecifierContext, bool $nativeTypesPromoted). Handlers whose narrowing needs an evaluation scope construct it from their captured beforeScope; child-result conduits go through getSpecifiedTypes() directly. The boolean narrowing helpers keep their MutatingScope evaluation-scope parameter, now always supplied by the handler. This closes the resolve-type-rewrite goal: an expression's narrowing is a pure function of (context, flavour), computed once at the expression's own position in the program. --- .../ExprHandler/ArrayDimFetchHandler.php | 4 ++-- .../ExprHandler/ArrowFunctionHandler.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 9 +++++---- src/Analyser/ExprHandler/AssignOpHandler.php | 2 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 17 +++++++++-------- src/Analyser/ExprHandler/BitwiseNotHandler.php | 2 +- src/Analyser/ExprHandler/BooleanAndHandler.php | 4 ++-- src/Analyser/ExprHandler/BooleanNotHandler.php | 4 ++-- src/Analyser/ExprHandler/BooleanOrHandler.php | 4 ++-- src/Analyser/ExprHandler/CastHandler.php | 10 +++++----- src/Analyser/ExprHandler/CastStringHandler.php | 4 ++-- .../ExprHandler/ClassConstFetchHandler.php | 2 +- src/Analyser/ExprHandler/CloneHandler.php | 2 +- src/Analyser/ExprHandler/ClosureHandler.php | 2 +- src/Analyser/ExprHandler/CoalesceHandler.php | 7 ++++--- src/Analyser/ExprHandler/ConstFetchHandler.php | 2 +- src/Analyser/ExprHandler/EmptyHandler.php | 5 +++-- .../ExprHandler/ErrorSuppressHandler.php | 2 +- src/Analyser/ExprHandler/EvalHandler.php | 2 +- src/Analyser/ExprHandler/ExitHandler.php | 2 +- src/Analyser/ExprHandler/FuncCallHandler.php | 6 ++---- src/Analyser/ExprHandler/IncludeHandler.php | 2 +- src/Analyser/ExprHandler/InstanceofHandler.php | 7 ++++--- .../ExprHandler/InterpolatedStringHandler.php | 2 +- src/Analyser/ExprHandler/IssetHandler.php | 11 +++++------ src/Analyser/ExprHandler/MatchHandler.php | 2 +- src/Analyser/ExprHandler/MethodCallHandler.php | 6 ++---- src/Analyser/ExprHandler/NewHandler.php | 6 ++---- .../ExprHandler/NullsafeMethodCallHandler.php | 4 +++- .../NullsafePropertyFetchHandler.php | 4 +++- src/Analyser/ExprHandler/PipeHandler.php | 2 +- src/Analyser/ExprHandler/PostDecHandler.php | 2 +- src/Analyser/ExprHandler/PostIncHandler.php | 2 +- src/Analyser/ExprHandler/PreDecHandler.php | 2 +- src/Analyser/ExprHandler/PreIncHandler.php | 2 +- src/Analyser/ExprHandler/PrintHandler.php | 2 +- .../ExprHandler/PropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/ShellExecHandler.php | 2 +- src/Analyser/ExprHandler/StaticCallHandler.php | 6 ++---- .../ExprHandler/StaticPropertyFetchHandler.php | 2 +- src/Analyser/ExprHandler/TernaryHandler.php | 3 ++- src/Analyser/ExprHandler/ThrowHandler.php | 2 +- src/Analyser/ExprHandler/UnaryMinusHandler.php | 2 +- src/Analyser/ExprHandler/UnaryPlusHandler.php | 2 +- src/Analyser/ExprHandler/VariableHandler.php | 2 +- .../Virtual/AlwaysRememberedExprHandler.php | 4 ++-- .../Virtual/FunctionCallableNodeHandler.php | 2 +- .../InstantiationCallableNodeHandler.php | 2 +- .../ExprHandler/Virtual/IssetExprHandler.php | 2 +- .../Virtual/MethodCallableNodeHandler.php | 2 +- .../Virtual/NativeTypeExprHandler.php | 2 +- .../Virtual/PossiblyImpureCallExprHandler.php | 2 +- .../Virtual/StaticMethodCallableNodeHandler.php | 2 +- .../ExprHandler/Virtual/TypeExprHandler.php | 2 +- src/Analyser/ExprHandler/YieldFromHandler.php | 2 +- src/Analyser/ExprHandler/YieldHandler.php | 2 +- src/Analyser/ExpressionResult.php | 9 +++------ src/Analyser/ExpressionResultFactory.php | 2 +- 58 files changed, 103 insertions(+), 105 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 203944b6433..7a69796aac5 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -66,7 +66,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex containsNullsafe: $varResult->containsNullsafe(), // `$arr[]` only appears as an assignment target; reading it is a NeverType typeCallback: static fn (): Type => new NeverType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -124,7 +124,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $shortCircuit($offsetAccessibleType->getOffsetValueType(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index aac27203ae9..570db0e1699 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -78,7 +78,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + specifyTypesCallback: fn (TypeSpecifierContext $c, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), type: $type, nativeType: $nativeType, typeCallback: null, diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index fa482314f3c..67b8d33c7f7 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -234,7 +234,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto ? $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $beforeScope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $beforeScope); }, - specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult, $beforeScope) : fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, ); } @@ -265,11 +265,12 @@ private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assi * The null-context inferences stay in specifyTypes() - result-based asks * are always truthy or falsey. * - * @return Closure(MutatingScope, TypeSpecifierContext): SpecifiedTypes + * @return Closure(TypeSpecifierContext, bool): SpecifiedTypes */ - private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Assign $expr, ?ExpressionResult $assignedExprResult): Closure + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Assign $expr, ?ExpressionResult $assignedExprResult, MutatingScope $beforeScope): Closure { - return function (MutatingScope $s, TypeSpecifierContext $context) use ($nodeScopeResolver, $expr, $assignedExprResult): SpecifiedTypes { + return function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($nodeScopeResolver, $expr, $assignedExprResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if ($context->null()) { $assignedScope = $s->exitFirstLevelStatements(); $result = $assignedExprResult ?? $assignedScope->obtainResultForNode($expr->expr); diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 5083c3ddf4d..53cd6c367db 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -152,7 +152,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); }; - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); $createTypesCallback = null; if ($expr instanceof Expr\AssignOp\Coalesce) { // a type constraint on `$x ??= y` constrains the assigned variable - diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 54f0b50c7cd..47a2ee123e1 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -255,7 +255,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: $typeCallback, - specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult, $typeCallback): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $leftArgResult, $rightArgResult, $typeCallback): SpecifiedTypes { + $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { // `!==` narrowing is the `===` narrowing in the negated context - // no synthetic Identical node. A null context never negates. @@ -272,14 +273,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr instanceof BinaryOp\NotIdentical ? $context->negate() : $context, // the narrowing composes on the evaluation scope; only the // asked flavour comes from the asking scope - $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $scope, $leftArgResult, $rightArgResult, // the comparison's own verdict, in Identical semantics - // computed from the captured operand results (the walk's // evaluation point), only the flavour follows the ask - static function () use ($expr, $scope, $typeCallback): Type { - $ownType = $typeCallback($scope->nativeTypesPromoted); + static function () use ($expr, $nativeTypesPromoted, $typeCallback): Type { + $ownType = $typeCallback($nativeTypesPromoted); if ($expr instanceof BinaryOp\NotIdentical) { if ($ownType->isTrue()->yes()) { return new ConstantBooleanType(false); @@ -311,7 +312,7 @@ static function () use ($expr, $scope, $typeCallback): Type { $leftResult, $rightResult, $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, - $scope->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $scope, $leftArgResult, $rightArgResult, ); @@ -357,12 +358,12 @@ static function () use ($expr, $scope, $typeCallback): Type { // already computed results instead of re-walking via // Scope::getType(). Their subexpressions (e.g. count() arguments) // were also processed and are read from the stored result. - $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver): Type { + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $nodeScopeResolver, $nativeTypesPromoted): Type { if ($e === $expr->left) { - return $scope->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); + return $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); } if ($e === $expr->right) { - return $scope->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + return $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); } return $nodeScopeResolver->readTypeOfMaybeStored($e, $scope); diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 232bd9372fa..881d00acb0f 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index fd0ee963feb..aa19daac89d 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -95,10 +95,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $scope): SpecifiedTypes { return $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, - $s, + $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $context, $expr, $expr->left, diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index d4986986099..6a35482d073 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -64,14 +64,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } // The negated operand was processed above; compose its narrowing // directly from its result rather than re-resolving the node. - return $exprResult->getSpecifiedTypesForScope($s, $context->negate())->setRootExpr($expr); + return $exprResult->getSpecifiedTypes($context->negate(), $nativeTypesPromoted)->setRootExpr($expr); }, ); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 0759e3ad567..c6dc0c32e0e 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -116,10 +116,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $scope): SpecifiedTypes { return $this->booleanNarrowingHelper->specifyDisjunction( $nodeScopeResolver, - $s, + $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $context, $expr, $expr->left, diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index 8439b5a394a..1766fa2574b 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -78,12 +78,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope, $subjectArgResult): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope, $subjectArgResult): SpecifiedTypes { + $evaluationScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; // a cast's truthiness is a loose comparison of the inner // expression - composed from its result; the fabricated // literal is only printed into entries, never walked if (($expr instanceof Cast\Bool_ || $expr instanceof Cast\Int_ || $expr instanceof Cast\Double) && !$context->null()) { - $evaluationScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if ($expr instanceof Cast\Bool_) { $literal = new ConstFetch(new FullyQualified('true')); $equalContext = $context; @@ -103,15 +103,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr instanceof Cast\Bool_) { - return $s->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + return $evaluationScope->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); } if ($expr instanceof Cast\Int_) { - return $s->obtainResultForNode(new NotEqual($expr->expr, new Int_(0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + return $evaluationScope->obtainResultForNode(new NotEqual($expr->expr, new Int_(0)))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); } if ($expr instanceof Cast\Double) { - return $s->obtainResultForNode(new NotEqual($expr->expr, new Float_(0.0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + return $evaluationScope->obtainResultForNode(new NotEqual($expr->expr, new Float_(0.0)))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); } return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 102ae599a5f..de3f27302af 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -71,9 +71,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $s->obtainResultForNode( + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => ($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope)->obtainResultForNode( new NotEqual($expr->expr, new String_('')), - )->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr), + )->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 6321d4eb7e7..349b0df1de2 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -106,7 +106,7 @@ static function (Expr $e) use ($classResult, $nativeTypesPromoted): Type { }, ); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 2aeec17852e..c890510777e 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $cloneType = TypeCombinator::intersect(($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); return TypeTraverser::map($cloneType, new CloneTypeTraverser()); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 591fcc32e2a..f5a02400211 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -74,7 +74,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + specifyTypesCallback: fn (TypeSpecifierContext $c, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), type: $type, nativeType: $nativeType, typeCallback: null, diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index a31e5bce51c..5264759f911 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -102,16 +102,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nativeTypesPromoted, ), - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if (!$context->true()) { - return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr->left, $condResult, $expr, $context); + return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $s, $expr->left, $condResult, $expr, $context); } - if ((new ConstantBooleanType(false))->isSuperTypeOf(($s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes()) { + if ((new ConstantBooleanType(false))->isSuperTypeOf(($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 2e0e9d2a549..86aa7eccb6c 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -92,7 +92,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ErrorType(); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index e5e4d002028..a35b7a60221 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -79,8 +79,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType(!$result); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope, &$foldScopes): SpecifiedTypes { - $isset = $exprResult->getIssetabilityResolution($s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static fn (): bool => true); + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope, &$foldScopes): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); if ($isset === false) { return new SpecifiedTypes(); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 4399034b59e..a26b3b8c671 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -49,7 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $exprResult->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr), ); } diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 31b7a571bd8..8d91fe55131 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -55,7 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 62a0ddb7baa..cdd85899af2 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -67,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 7a3acfba163..9f7f6553833 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -341,11 +341,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $argsResult, ); - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - // the narrowing computes on the evaluation scope; only the asked - // flavour comes from the asking scope - $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $nameResult, diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 6459565a391..f56de2f3b48 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 05d58dd7271..731c60b634f 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -157,7 +157,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new BooleanType(); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $classResult, $nameNarrowType): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $classResult, $nameNarrowType, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; $exprNode = $expr->expr; if ($expr->class instanceof Name) { if ($nameNarrowType === null) { @@ -171,7 +172,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($classResult === null) { throw new ShouldNotHappenException(); } - $classNameType = $classResult->getTypeOnScope($s, $s->nativeTypesPromoted); + $classNameType = $classResult->getTypeOnScope($s, $nativeTypesPromoted); $result = $classNameType->toObjectTypeForInstanceofCheck(); $type = $result->type; $uncertainty = $result->uncertainty; @@ -184,7 +185,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } elseif ($context->false() && !$uncertainty) { - $exprType = $exprResult->getTypeOnScope($s, $s->nativeTypesPromoted); + $exprType = $exprResult->getTypeOnScope($s, $nativeTypesPromoted); if (!$type->isSuperTypeOf($exprType)->yes()) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index b784767573d..e566633bdbb 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -98,7 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $resultType ?? new ConstantStringType(''); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 56c6b1bdae8..2f967e30b5a 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -171,11 +171,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType($issetResult); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $beforeScope, &$foldAccTypes): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $beforeScope, &$foldAccTypes): SpecifiedTypes { // type of an already-processed chain link, read from its captured - // result on the evaluation point (only the flavour follows the - // asking scope) - never re-walked through the scope - $evaluationScope = $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // result on the evaluation point - never re-walked through the scope + $evaluationScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope, $nodeScopeResolver); if (count($expr->vars) === 0 || $context->null()) { @@ -219,7 +218,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // not the asking scope - the accumulated conjunction closure // is ask-independent and built once, reused across asks if ($foldAccTypes !== null) { - return $foldAccTypes($s, $context)->setRootExpr($expr); + return $foldAccTypes($evaluationScope, $context)->setRootExpr($expr); } $accExpr = new Isset_([$expr->vars[0]], $expr->getAttributes()); @@ -256,7 +255,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $foldAccTypes = $accTypes; - return $accTypes($s, $context)->setRootExpr($expr); + return $accTypes($evaluationScope, $context)->setRootExpr($expr); } $issetExpr = $expr->vars[0]; diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 4446ca16b9d..6c3900fcdac 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -500,7 +500,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return TypeCombinator::union(...$types); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index e2298910a36..c21602f4348 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -193,11 +193,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $argsResult, ); - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - // the narrowing computes on the evaluation scope; only the asked - // flavour comes from the asking scope - $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $varResult, diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 37cd199f57f..9f12ee8ad26 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -236,10 +236,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nativeTypesPromoted ? null : $resolvedParametersAcceptor, ); - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( - // the narrowing computes on the evaluation scope; only the asked - // flavour comes from the asking scope - $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $resolvedParametersAcceptor, $specifyContext, diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index cbd1b9b1046..2c46ccf3342 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -130,11 +130,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // `$x?->...` narrows like ($x !== null) && $x->..., composed from // the captured receiver and plain-twin results - the fabricated // NotIdentical is only printed into holder keys, never walked diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 446eaebe952..1f8c0675de3 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -110,11 +110,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, typeCallback: $nullsafeTypeCallback, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { if ($context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // `$x?->...` narrows like ($x !== null) && $x->..., composed from // the captured receiver and plain-twin results - the fabricated // NotIdentical is only printed into holder keys, never walked diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 05390a63c03..860f479f8f8 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -106,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $callResult->getImpurePoints(), // the pipe evaluates to its rewritten call - read that child's result typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callResult->getNativeType() : $callResult->getType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index b466ea9258d..c37612c6c75 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -59,7 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), // post-decrement evaluates to the variable's pre-mutation value typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 94c587b1e10..e35d06ec32f 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -59,7 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), // post-increment evaluates to the variable's pre-mutation value typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 7c7d4d27271..569996ab484 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -117,7 +117,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }); }; - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); // processVirtualAssign asks getType($expr) for the value to assign; store // this result first so that resolves from the typeCallback below rather diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index b39f30bc872..47b62f9e81a 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -118,7 +118,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }); }; - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); // processVirtualAssign asks getType($expr) for the value to assign; store // this result first so that resolves from the typeCallback below rather diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 30a81985eb4..e6b3149db8e 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -63,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), typeCallback: static fn (bool $nativeTypesPromoted): Type => new ConstantIntegerType(1), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 895bf7a9817..02cf9c31f9d 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -149,7 +149,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new MixedType(); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/ShellExecHandler.php b/src/Analyser/ExprHandler/ShellExecHandler.php index 7c9f7ce674d..27c71f8c2d2 100644 --- a/src/Analyser/ExprHandler/ShellExecHandler.php +++ b/src/Analyser/ExprHandler/ShellExecHandler.php @@ -80,7 +80,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static fn (bool $nativeTypesPromoted): Type => TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index b143f056571..45d51c057a0 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -270,11 +270,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $argsResult, ); - $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( $nodeScopeResolver, - // the narrowing computes on the evaluation scope; only the asked - // flavour comes from the asking scope - $s->nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $normalizedExpr, $classResult, diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 86080fbeb5d..8be43b7ce69 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -156,7 +156,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new MixedType(); }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index d1ed9460556..41ef9e0e7a5 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -171,7 +171,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseType, ); }, - specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; if ($expr->cond instanceof Ternary || $context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 37799e2b7be..cea16377a81 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getType(), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 0ab65d5e884..4d30a76fc1a 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -59,7 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // operand) created inside getUnaryMinusType - priced on demand return $nodeScopeResolver->processSyntheticOnDemand($e, $scope)->getTypeOnScope($scope, $nativeTypesPromoted); }), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index a4f2bb4f1c0..97918a7d7fa 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throw new ShouldNotHappenException(); }), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 1738d2c9f76..9e6996275a4 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -153,7 +153,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, typeCallback: $this->createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult, is_string($expr->name) ? null : $this->identicalNarrowingHelper->captureFirstArgResult($expr->name, $storage)), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 8954e456941..99a4150c354 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -64,8 +64,8 @@ public function processExpr( // Narrowing by the remembered wrapper is narrowing by the inner // expression (TypeSpecifier unwrapped it and specified both keys); // the wrapper node itself keeps the default truthy/falsey entry. - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)->unionWith( - $innerResult->getSpecifiedTypesForScope($s, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)->unionWith( + $innerResult->getSpecifiedTypes($context, $nativeTypesPromoted), ), // A type constraint on the remembered wrapper constrains both the wrapper // node (under its __phpstanRemembered(...) key) and the inner expression - diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index a64bc4931ff..b15dd374011 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -68,7 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $nameResult), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 2fed5a5cce8..492d3f3bfaa 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -64,7 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php index 37f39ad8b2a..6f35637c634 100644 --- a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readTypeOfMaybeStored($expr->getExpr(), $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 1f65ec270ba..19a1bc47f86 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -69,7 +69,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $varResult), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 10e30c6061f..35fbebae239 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php index 010a0153021..92bb8f84c70 100644 --- a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readTypeOfMaybeStored($expr->callExpr, $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index b254440fa82..b3225d5336a 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -73,7 +73,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 5394e117adf..2e0f1053d80 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -50,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => $expr->getExprType(), - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 7fcc9fbb34a..a7b8061c427 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -65,7 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $generatorReturnType; }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 1c3cc3ec445..b7622910880 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -98,7 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $generatorSendType; }, - specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index aee6bf381cd..666a319d55f 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -20,7 +20,7 @@ final class ExpressionResult /** @var (callable(bool): Type)|null */ private $typeCallback; - /** @var callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ + /** @var callable(TypeSpecifierContext, bool): SpecifiedTypes */ private $specifyTypesCallback; /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ @@ -49,7 +49,7 @@ final class ExpressionResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param (callable(bool): Type)|null $typeCallback - * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback + * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ public function __construct( @@ -356,10 +356,7 @@ public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTyp { $key = (spl_object_id($context) << 1) | ($nativeTypesPromoted ? 1 : 0); - return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)( - $nativeTypesPromoted ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope, - $context, - ); + return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted); } /** diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index 9c8af7d3f01..e080cd39a9c 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -12,7 +12,7 @@ interface ExpressionResultFactory * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param (callable(bool): Type)|null $typeCallback - * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback + * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ public function create( From 53482f865881da4dc91e5445ee39541d16d30690 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 00:07:14 +0200 Subject: [PATCH 317/378] Port the coalesce falsey-context gate to the composed narrowing Upstream's fix (b1c0eb2429): the right-side-decidedly-false narrowing of ?? must not narrow the left to non-null when the coalesce context still permits a falsey value. Re-applied to the composed specify callback the rebase replayed over it. --- src/Analyser/ExprHandler/CoalesceHandler.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 5264759f911..837f242c1fb 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -112,7 +112,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $s, $expr->left, $condResult, $expr, $context); } - if ((new ConstantBooleanType(false))->isSuperTypeOf(($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes()) { + if ( + !$context->falsey() + && (new ConstantBooleanType(false))->isSuperTypeOf(($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes() + ) { return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); } From 22b31b4f72c61ccf8a127c75b752550d5ce600fd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 00:39:35 +0200 Subject: [PATCH 318/378] Resolve boolean-decomposition branch scopes lazily The boolean narrowing helpers took the operand branch scopes as eager arguments, so every specify ask on a deep boolean chain derived the opposite-polarity scope of every level - the walk only computes falsey scopes for || (and truthy for &&), so each missing one cost an applySpecifiedTypes with a growing union, quadratic over the chain. They are thunks now, resolved only when a consumer genuinely needs the state: the branch-union recovery when it has candidate expressions, the holder projection when a candidate passes the applying-scope gates, holder recipes when a non-variable target needs pinning. Net analysis time of tests/bench/data/or-chain-resolve-type-blowup.php drops from ~5s to ~1.2s, level with the 2.2.x baseline; the remaining super-linear member flows (the growing object~(C1|...|Ck) subtraction re-normalized per level) are shared with the old-world representation. --- .../DisjunctionHolderProjectionAugment.php | 22 ++++++++++----- .../ExprHandler/BooleanAndHandler.php | 6 ++--- src/Analyser/ExprHandler/BooleanOrHandler.php | 6 ++--- src/Analyser/ExprHandler/EmptyHandler.php | 6 ++--- .../Helper/BooleanNarrowingHelper.php | 27 +++++++++++++------ .../ConditionalExpressionHolderHelper.php | 23 +++++++++++----- src/Analyser/ExprHandler/IssetHandler.php | 6 ++--- .../ExprHandler/NullsafeMethodCallHandler.php | 16 +++++------ .../NullsafePropertyFetchHandler.php | 16 +++++------ src/Analyser/ExprHandler/TernaryHandler.php | 23 +++++++++------- 10 files changed, 93 insertions(+), 58 deletions(-) diff --git a/src/Analyser/DisjunctionHolderProjectionAugment.php b/src/Analyser/DisjunctionHolderProjectionAugment.php index 980345de33c..dcf324714ac 100644 --- a/src/Analyser/DisjunctionHolderProjectionAugment.php +++ b/src/Analyser/DisjunctionHolderProjectionAugment.php @@ -19,6 +19,12 @@ final class DisjunctionHolderProjectionAugment implements DeferredSpecifiedTypes { /** + * The operand truthy scopes are thunks resolved only when a candidate + * passes the applying-scope gates - deriving them per level of a deep + * boolean chain is quadratic. + * + * @param callable(): MutatingScope $leftTruthyScope + * @param callable(): MutatingScope $rightTruthyScope * @param array $alternativeKeys expressions the exact either-branch * merge already constrains - the weaker branch-scope union must not * be added on top @@ -26,9 +32,9 @@ final class DisjunctionHolderProjectionAugment implements DeferredSpecifiedTypes public function __construct( private NodeScopeResolver $nodeScopeResolver, private DefaultNarrowingHelper $defaultNarrowingHelper, - private MutatingScope $leftTruthyScope, + private $leftTruthyScope, private MutatingScope $leftFalseyScope, - private MutatingScope $rightTruthyScope, + private $rightTruthyScope, private array $alternativeKeys, ) { @@ -38,6 +44,8 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes { $result = null; $seen = []; + $leftTruthyScope = null; + $rightTruthyScope = null; foreach ([$scope, $this->leftFalseyScope] as $sourceScope) { foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) { if (isset($seen[$rootExprString])) { @@ -61,16 +69,18 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes if (!$scope->hasExpressionType($targetExpr)->yes()) { continue; } - if (!$this->leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + $leftTruthyScope ??= ($this->leftTruthyScope)(); + $rightTruthyScope ??= ($this->rightTruthyScope)(); + if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { continue; } - if (!$this->rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { continue; } $origType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); - $leftType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $this->leftTruthyScope); - $rightType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $this->rightTruthyScope); + $leftType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); + $rightType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index aa19daac89d..6964bf0b8d5 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -103,11 +103,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $expr->left, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), - $leftResult->getTruthyScope(), - $leftResult->getFalseyScope(), + static fn (): MutatingScope => $leftResult->getTruthyScope(), + static fn (): MutatingScope => $leftResult->getFalseyScope(), $expr->right, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), - $rightResult->getFalseyScope(), + static fn (): MutatingScope => $rightResult->getFalseyScope(), ); }, ); diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index c6dc0c32e0e..26f5bfbb6c3 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -125,12 +125,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->left, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(), - $leftResult->getTruthyScope(), - $leftResult->getFalseyScope(), + static fn (): MutatingScope => $leftResult->getTruthyScope(), + static fn (): MutatingScope => $leftResult->getFalseyScope(), $expr->right, static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(), - $rightResult->getTruthyScope(), + static fn (): MutatingScope => $rightResult->getTruthyScope(), ); }, ); diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index a35b7a60221..1e94f7470b5 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -161,12 +161,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $notIssetNode, $leftTypes, $leftType, - $leftTruthyScope, - $leftFalseyScope, + static fn (): MutatingScope => $leftTruthyScope, + static fn (): MutatingScope => $leftFalseyScope, $notExprNode, $rightTypes, $rightType, - $rightTruthyScope, + static fn (): MutatingScope => $rightTruthyScope, )->setRootExpr($expr); }, ); diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index 34108ca6473..e24fb3f9739 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -32,8 +32,16 @@ public function __construct( } /** + * The branch scopes are thunks: for a deep boolean chain, eagerly deriving + * every level's opposite-polarity scope is quadratic - they resolve only + * when a consumer (an augment with candidates, a holder re-derivation) + * genuinely needs the state. + * * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $leftTypesCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $rightTypesCallback + * @param callable(): MutatingScope $leftTruthyScope + * @param callable(): MutatingScope $leftFalseyScope + * @param callable(): MutatingScope $rightFalseyScope */ public function specifyConjunction( NodeScopeResolver $nodeScopeResolver, @@ -42,17 +50,17 @@ public function specifyConjunction( Expr $rootExpr, Expr $leftExpr, callable $leftTypesCallback, - MutatingScope $leftTruthyScope, - MutatingScope $leftFalseyScope, + callable $leftTruthyScope, + callable $leftFalseyScope, Expr $rightExpr, callable $rightTypesCallback, - MutatingScope $rightFalseyScope, + callable $rightFalseyScope, ): SpecifiedTypes { $leftTypes = $leftTypesCallback($s, $context)->setRootExpr($rootExpr); // the right operand lives after the left is known true - its narrowing // bases read from the left-truthy view, never the raw ask scope - $rightScope = $leftTruthyScope; + $rightScope = $leftTruthyScope(); $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); @@ -133,6 +141,9 @@ public function specifyConjunction( * @param callable(bool): Type $leftTypeCallback * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $rightTypesCallback * @param callable(bool): Type $rightTypeCallback + * @param callable(): MutatingScope $leftTruthyScope + * @param callable(): MutatingScope $leftFalseyScope + * @param callable(): MutatingScope $rightTruthyScope */ public function specifyDisjunction( NodeScopeResolver $nodeScopeResolver, @@ -142,16 +153,16 @@ public function specifyDisjunction( Expr $leftExpr, callable $leftTypesCallback, callable $leftTypeCallback, - MutatingScope $leftTruthyScope, - MutatingScope $leftFalseyScope, + callable $leftTruthyScope, + callable $leftFalseyScope, Expr $rightExpr, callable $rightTypesCallback, callable $rightTypeCallback, - MutatingScope $rightTruthyScope, + callable $rightTruthyScope, ): SpecifiedTypes { $leftTypes = $leftTypesCallback($s, $context)->setRootExpr($rootExpr); - $rightScope = $leftFalseyScope; + $rightScope = $leftFalseyScope(); $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index db0f35efb85..5ad6a640ec8 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -35,13 +35,20 @@ public function __construct( * branch types are read from the operand-walk filtered scopes here at * compose time, while the does-it-actually-narrow gates run against the * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. + * + * The filtered scopes are thunks resolved only when there are candidate + * expressions - deriving them per level of a deep boolean chain is + * quadratic. + * + * @param callable(): MutatingScope $leftFilteredScope + * @param callable(): MutatingScope $rightFilteredScope */ public function buildBranchUnionAugment( NodeScopeResolver $nodeScopeResolver, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes, - MutatingScope $leftFilteredScope, - MutatingScope $rightFilteredScope, + callable $leftFilteredScope, + callable $rightFilteredScope, SpecifiedTypes $types, ): ?DisjunctionBranchUnionAugment { @@ -57,6 +64,8 @@ public function buildBranchUnionAugment( $existingAlternativeTypes = $types->getAlternativeTypes(); $candidates = []; + $leftScope = null; + $rightScope = null; foreach ($candidateExprs as $exprString => $targetExpr) { if (isset($existingSureTypes[$exprString])) { continue; @@ -67,10 +76,12 @@ public function buildBranchUnionAugment( if (isset($existingAlternativeTypes[$exprString])) { continue; } - if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) { + $leftScope ??= $leftFilteredScope(); + $rightScope ??= $rightFilteredScope(); + if (!$leftScope->hasExpressionType($targetExpr)->yes()) { continue; } - if (!$rightFilteredScope->hasExpressionType($targetExpr)->yes()) { + if (!$rightScope->hasExpressionType($targetExpr)->yes()) { continue; } @@ -78,8 +89,8 @@ public function buildBranchUnionAugment( // results on these filtered scopes instead of re-walking via getType(). $candidates[] = [ $targetExpr, - $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftFilteredScope), - $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightFilteredScope), + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftScope), + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightScope), ]; } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 2f967e30b5a..eefd6955cfb 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -242,11 +242,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $leftExprNode, $leftTypes, - $leftTruthyScope, - $leftFalseyScope, + static fn (): MutatingScope => $leftTruthyScope, + static fn (): MutatingScope => $leftFalseyScope, $rightExprNode, $rightTypes, - $rightFalseyScope, + static fn (): MutatingScope => $rightFalseyScope, ); $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 2c46ccf3342..9e224ef52e0 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -150,11 +150,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }; $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); - // the plain twin was walked on the ensured-non-null scope - that - // is the left-truthy evaluation point; the receiver-is-null - // branch scope has no walk analog and derives lazily - $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); - $types = $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $s, @@ -162,11 +157,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $notIdenticalNode, $leftTypes, - $nonNullabilityResult->getScope(), - $leftFalseyScope, + static fn (): MutatingScope => $nonNullabilityResult->getScope(), + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives on first demand + static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingScope { + return $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + }, $methodCall, $rightTypes, - $exprResult->getFalseyScope(), + static fn (): MutatingScope => $exprResult->getFalseyScope(), )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 1f8c0675de3..d13c1d60119 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -130,11 +130,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }; $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); - // the plain twin was walked on the ensured-non-null scope - that - // is the left-truthy evaluation point; the receiver-is-null - // branch scope has no walk analog and derives lazily - $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); - $types = $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $s, @@ -142,11 +137,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $notIdenticalNode, $leftTypes, - $nonNullabilityResult->getScope(), - $leftFalseyScope, + static fn (): MutatingScope => $nonNullabilityResult->getScope(), + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives on first demand + static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingScope { + return $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + }, $propertyFetch, $rightTypes, - $exprResult->getFalseyScope(), + static fn (): MutatingScope => $exprResult->getFalseyScope(), )->setRootExpr($expr); $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 41ef9e0e7a5..5e777fbd191 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -214,13 +214,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType(); // the decomposition's branch scopes are the operand walks' own - // memoized branch scopes (the evaluation points), not ask-derived - $condTruthyScope = $ternaryCondResult->getTruthyScope(); - $condFalseyScope = $ternaryCondResult->getFalseyScope(); + // memoized branch scopes (the evaluation points), not ask-derived; + // thunked so deep chains do not derive every level eagerly + $condTruthyScope = static fn (): MutatingScope => $ternaryCondResult->getTruthyScope(); + $condFalseyScope = static fn (): MutatingScope => $ternaryCondResult->getFalseyScope(); // right disjunct: !cond && else $bNode = new BooleanAnd($notCondNode, $expr->else); - $elseFalseyOnCondFalseyScope = $elseResult->getFalseyScope(); + $elseFalseyOnCondFalseyScope = static fn (): MutatingScope => $elseResult->getFalseyScope(); $bTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $scope, @@ -235,14 +236,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseFalseyOnCondFalseyScope, ); $bType = $andVerdict($notCondType, $elseType); - $bTruthyScope = $elseResult->getTruthyScope(); + $bTruthyScope = static fn (): MutatingScope => $elseResult->getTruthyScope(); if ($ifResult !== null && $expr->if !== null) { // left disjunct: cond && if $aNode = new BooleanAnd($expr->cond, $expr->if); $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); $ifType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType(); - $ifFalseyOnCondTruthyScope = $ifResult->getFalseyScope(); + $ifFalseyOnCondTruthyScope = static fn (): MutatingScope => $ifResult->getFalseyScope(); $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, $scope, @@ -257,10 +258,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseyOnCondTruthyScope, ); $aType = $andVerdict($condType, $ifType); - $aTruthyScope = $ifResult->getTruthyScope(); + $aTruthyScope = static fn (): MutatingScope => $ifResult->getTruthyScope(); // the merged falsey of (cond && if) has no single walk scope - - // derived from the evaluation point once, reused across asks - $aFalseyScope ??= $scope->applySpecifiedTypes($aTypes($scope, TypeSpecifierContext::createFalsey())); + // derived from the evaluation point on first demand, reused across asks + $aFalseyScopeThunk = static function () use ($scope, $aTypes, &$aFalseyScope): MutatingScope { + return $aFalseyScope ??= $scope->applySpecifiedTypes($aTypes($scope, TypeSpecifierContext::createFalsey())); + }; return $this->booleanNarrowingHelper->specifyDisjunction( $nodeScopeResolver, @@ -271,7 +274,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $aTypes, $aType, $aTruthyScope, - $aFalseyScope, + $aFalseyScopeThunk, $bNode, $bTypes, $bType, From 9c844844ad3183a1f443e43eb3987e20037a1dd4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 08:42:14 +0200 Subject: [PATCH 319/378] Flip createTypesCallback to (Type, TypeSpecifierContext, bool) The create-side analog of the specifyTypesCallback flip: a type constraint's fan-out is computed at the expression's own evaluation point, with the asking scope reduced to its flavour bit. ExpressionResult::getCreatedTypes() is the new entry point; getCreatedTypesForScope() delegates. Handlers construct their evaluation scope from the captured beforeScope, the same recipe as the specify flip. --- src/Analyser/ExprHandler/AssignHandler.php | 9 +++++---- src/Analyser/ExprHandler/AssignOpHandler.php | 6 +++++- src/Analyser/ExprHandler/CoalesceHandler.php | 5 +++-- src/Analyser/ExprHandler/FuncCallHandler.php | 3 ++- src/Analyser/ExprHandler/MethodCallHandler.php | 10 +++++++--- .../ExprHandler/NullsafeMethodCallHandler.php | 5 +++-- .../ExprHandler/NullsafePropertyFetchHandler.php | 5 +++-- src/Analyser/ExprHandler/StaticCallHandler.php | 10 +++++++--- .../Virtual/AlwaysRememberedExprHandler.php | 10 +++++++--- src/Analyser/ExpressionResult.php | 16 +++++++++++++--- src/Analyser/ExpressionResultFactory.php | 2 +- 11 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 67b8d33c7f7..864aa46687f 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -235,7 +235,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto : $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $beforeScope); }, specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult, $beforeScope) : fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), - createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : null, + createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult, $beforeScope) : null, ); } @@ -245,11 +245,12 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto * by unwrapping assign chains. Nested assignments compose through the * assigned expression's own result. * - * @return Closure(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes + * @return Closure(Type, TypeSpecifierContext, bool): SpecifiedTypes */ - private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult): Closure + private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult, MutatingScope $beforeScope): Closure { - return function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $assignedExprResult): SpecifiedTypes { + return function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $assignedExprResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; $types = $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, $type, $context); return $types->unionWith( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 53cd6c367db..989ae359c64 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -157,7 +157,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr instanceof Expr\AssignOp\Coalesce) { // a type constraint on `$x ??= y` constrains the assigned variable - // what TypeSpecifier::create() recovered by its AssignOp\Coalesce arm - $createTypesCallback = fn (MutatingScope $cs, Type $constraintType, TypeSpecifierContext $cctx): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $nodeScopeResolver->findStoredResult($expr->var, $cs), $constraintType, $cctx); + $createTypesCallback = function (Type $constraintType, TypeSpecifierContext $cctx, bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + $cs = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $nodeScopeResolver->findStoredResult($expr->var, $cs), $constraintType, $cctx); + }; } // processAssignVar asks getType($expr) for the value to assign; store this diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index 837f242c1fb..0a9fa274e68 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -127,9 +127,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // a type constraint on the coalesce constrains its left side when // the type rules the right side in or out - what // TypeSpecifier::create() recovered by unwrapping the coalesce - createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if (!$context->null()) { - $rightType = $s->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); if ( ($context->true() && $type->isSuperTypeOf($rightType)->no()) || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 9f7f6553833..a53cd704db8 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -355,7 +355,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // function call narrows the call itself - the inside-out equivalent of // createForExpr's FuncCall purity gate + tail entry. An impure call narrows to // nothing. - $createTypesCallback = function (MutatingScope $s, Type $type, TypeSpecifierContext $createContext) use ($nodeScopeResolver, $expr, $nameResult): SpecifiedTypes { + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($nodeScopeResolver, $expr, $nameResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; if (!$this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult)) { return new SpecifiedTypes([], []); } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index c21602f4348..6eb9a82a300 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -206,9 +206,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A type constraint on a (narrowable, i.e. non-side-effecting) method call // narrows the call itself - the inside-out equivalent of createForExpr's // MethodCall purity gate + tail entry. An impure call narrows to nothing. - $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isMethodCallNarrowable($s, $expr, $varResult) - ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) - : new SpecifiedTypes([], []); + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($expr, $varResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->isMethodCallNarrowable($s, $expr, $varResult) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + }; // Store a preliminary result carrying the type/specify callbacks before the // throw point is computed: the method throw point resolves the return type diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 9e224ef52e0..3555982ed5c 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -175,14 +175,15 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: // a receiver that is itself a ?-> composes through the parent handler. - createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $methodCall, $exprResult, $nullsafeTypeCallback): SpecifiedTypes { + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { // null() context: createForExpr never computes $containsNull and // emits no entry for the subject - behave the same. if ($context->null()) { return (new SpecifiedTypes())->setRootExpr($expr); } - $nullsafeType = $nullsafeTypeCallback($s->nativeTypesPromoted); + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $nullsafeType = $nullsafeTypeCallback($nativeTypesPromoted); if ($context->true()) { $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); } else { diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index d13c1d60119..93a3e15732e 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -155,14 +155,15 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: // a receiver that is itself a ?-> composes through the parent handler. - createTypesCallback: function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $propertyFetch, $exprResult, $nullsafeTypeCallback): SpecifiedTypes { + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { // null() context: createForExpr never computes $containsNull and // emits no entry for the subject - behave the same. if ($context->null()) { return (new SpecifiedTypes())->setRootExpr($expr); } - $nullsafeType = $nullsafeTypeCallback($s->nativeTypesPromoted); + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $nullsafeType = $nullsafeTypeCallback($nativeTypesPromoted); if ($context->true()) { $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); } else { diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 45d51c057a0..4efa10e2781 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -283,9 +283,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // A type constraint on a (narrowable, i.e. non-side-effecting) static call // narrows the call itself - the inside-out equivalent of createForExpr's // StaticCall purity gate + tail entry. An impure call narrows to nothing. - $createTypesCallback = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isStaticCallNarrowable($s, $expr, $classResult, $nodeScopeResolver) - ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) - : new SpecifiedTypes([], []); + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($expr, $classResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->isStaticCallNarrowable($s, $expr, $classResult, $nodeScopeResolver) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + }; // Store a preliminary result carrying the type/specify callbacks before the // throw point is computed: the method throw point resolves the return type diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 99a4150c354..4fda86a3730 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -72,9 +72,13 @@ public function processExpr( // what TypeSpecifier::create() recovered by fanning the AlwaysRememberedExpr // out into wrapper + inner. The inner composes through its own child result; // raw-Expr callers still go through create()->createForExpr. - createTypesCallback: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->unionWith( - $this->defaultNarrowingHelper->createSubjectTypes($s, $innerExpr, $innerResult, $type, $context), - ), + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $innerExpr, $innerResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $innerExpr, $innerResult, $type, $context), + ); + }, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 666a319d55f..6c0a3193b1b 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -23,7 +23,7 @@ final class ExpressionResult /** @var callable(TypeSpecifierContext, bool): SpecifiedTypes */ private $specifyTypesCallback; - /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ + /** @var (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null */ private $createTypesCallback; /** @var array */ @@ -50,7 +50,7 @@ final class ExpressionResult * @param ImpurePoint[] $impurePoints * @param (callable(bool): Type)|null $typeCallback * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback - * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback + * @param (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null $createTypesCallback */ public function __construct( private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, @@ -370,12 +370,22 @@ public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTyp * caller emits a single entry for the expression itself. */ public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSpecifierContext $context): ?SpecifiedTypes + { + return $this->getCreatedTypes($type, $context, $scope->nativeTypesPromoted); + } + + /** + * The narrowing entries a type constraint on this expression fans out to, + * computed at the expression's own evaluation point - the asking scope + * reduces to its flavour bit, like getSpecifiedTypes(). + */ + public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted = false): ?SpecifiedTypes { if ($this->createTypesCallback === null) { return null; } - return ($this->createTypesCallback)($scope, $type, $context); + return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted); } /** diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index e080cd39a9c..1741330494d 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -13,7 +13,7 @@ interface ExpressionResultFactory * @param ImpurePoint[] $impurePoints * @param (callable(bool): Type)|null $typeCallback * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback - * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback + * @param (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null $createTypesCallback */ public function create( MutatingScope $scope, From ecb54d2367c4a34112a013ef787eedaf914a1b14 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 09:17:20 +0200 Subject: [PATCH 320/378] Read match arm verdicts from the threaded per-arm state Two reads made every match arm see the full original subject type instead of the subject minus the previous arms' values: - MatchHandler computed its arm verdicts from the subject result's walk-position type; they now read the threaded per-arm scope state (getStateType), which carries the previous arms' subtractions. - A rule asking about a synthetic comparison (MatchExpressionRule's Identical($subject, $armValue)) walks it on demand over a duplicate of the live storage, so the subject hit its stored result and answered its walk-position type - the per-scope re-pricing channel the typeCallback flip removed. BinaryOpHandler's operand reader now re-prices operands via getTypeOnScope() with variables opted in (the new repriceVariables parameter), reading them from the walk's own beforeScope - the asking scope for an on-demand synthetic. Fixes ten MatchExpressionRuleTest known-reds; the walk-position semantics of stored-result reads and chain folds are unchanged. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 18 +++++++++++++++--- src/Analyser/ExprHandler/MatchHandler.php | 14 ++++++++------ src/Analyser/ExpressionResult.php | 19 ++++++++++++++++++- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 47a2ee123e1..49dbe5638de 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -122,14 +122,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are // priced on demand by the same helper. $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $nativeTypesPromoted, $beforeScope, $nodeScopeResolver): Type { + // getTypeOnScope re-prices narrowable operands against this + // result's OWN beforeScope: for the main walk that is the walk + // position (identical to getType()), but for an on-demand walk + // of a synthetic (a rule asking about Identical($x, ...) on an + // arm-narrowed scope) it is the asking scope, whose tracked + // narrowing the stored operand results predate + $flavouredScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // operands are re-priced from this result's own beforeScope + // (with variables opted in): for the main walk that is the walk + // position, but for an on-demand walk of a synthetic (a rule + // asking about Identical($x, ...) on an arm-narrowed scope) it + // carries narrowing the stored operand results predate if ($e === $expr->left) { - return ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType()); + return $leftResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted, true); } if ($e === $expr->right) { - return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); + return $rightResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted, true); } - return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); + return $nodeScopeResolver->readTypeOfMaybeStored($e, $flavouredScope); }; if ($expr instanceof BinaryOp\Smaller) { diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 6c3900fcdac..6981c72b3ee 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -342,11 +342,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); $armCondResultScope = $armCondResult->getScope(); // the `subject === cond` verdict and both narrowing contexts, - // composed from the subject's and the condition's walk results - - // no synthetic Identical walk (mirrors BinaryOpHandler's seam) + // composed from the subject's THREADED per-arm state (carrying + // the previous arms' subtractions) and the condition's walk + // result - no synthetic Identical walk + $armSubjectType = $armCondResultScope->getStateType($expr->cond); $armCondType = $this->treatPhpDocTypesAsCertain - ? $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $condResult->getType(), $armCondResult->getType())->type - : $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope->doNotTreatPhpDocTypesAsCertain(), $armCondExpr, $nodeScopeResolver, $condResult->getNativeType(), $armCondResult->getNativeType())->type; + ? $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $armSubjectType, $armCondResult->getType())->type + : $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope->doNotTreatPhpDocTypesAsCertain(), $armCondExpr, $nodeScopeResolver, $armCondResultScope->doNotTreatPhpDocTypesAsCertain()->getStateType($expr->cond), $armCondResult->getNativeType())->type; if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } @@ -361,7 +363,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $armCondResultScope, $condArgResult, $armCondArgResult, - fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $condResult->getType(), $armCondResult->getType())->type, + fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $armCondResultScope->getStateType($expr->cond), $armCondResult->getType())->type, ) ?? $this->defaultNarrowingHelper->specifyDefaultTypes($armCondExpr, $specifyContext))->setRootExpr($armCondExpr); $armCondScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createFalsey())); $armCondTruthyScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createTruthy())); @@ -385,7 +387,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } [$filteringCond, $filteringCondResult] = $filteringCondData[0]; $filteringIdentical = new BinaryOp\Identical($expr->cond, $filteringCond); - $filteringExprType = $this->richerScopeGetTypeHelper->getIdenticalResult($matchScope, $filteringIdentical, $nodeScopeResolver, $condResult->getType(), $filteringCondResult->getType())->type; + $filteringExprType = $this->richerScopeGetTypeHelper->getIdenticalResult($matchScope, $filteringIdentical, $nodeScopeResolver, $matchScope->getStateType($expr->cond), $filteringCondResult->getType())->type; // the falsey narrowing stays a synthetic walk: the walk re-prices // the subject on the arm-narrowed scope, and that progressive // narrowing (each arm sees the subject minus the previous arms' diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 6c0a3193b1b..e178cc837b0 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -11,6 +11,7 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; +use function is_string; use function spl_object_id; #[GenerateFactory(interface: ExpressionResultFactory::class)] @@ -395,13 +396,29 @@ public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool * consumers - isset/empty/?? chain folding and the stored-result read in * NodeScopeResolver - everything else reads getType()/getNativeType(). */ - public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type + public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes, bool $repriceVariables = false): Type { $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; if ($this->type === null && $this->hasTrackedExpressionType($readScope)) { return $readScope->getTrackedExpressionType($this->expr); } + // rule-facing asks re-price a variable from the given scope: it may + // carry narrowing this result's walk position predates (a rule asking + // about a synthetic comparison on an arm-narrowed scope). The walk's + // own consumers (stored-result reads, chain folds) keep the memoized + // walk-position type - their scopes can also be WIDER (merged, loop + // converged) than the position the value flowed from. + if ( + $repriceVariables + && $this->type === null + && $this->expr instanceof Expr\Variable + && is_string($this->expr->name) + && !$readScope->hasVariableType($this->expr->name)->no() + ) { + return $readScope->getVariableType($this->expr->name); + } + return $this->resolveOwnType($useNativeTypes); } From 97789ceaf68f47c73d8311f222fb8f3c68fd52a8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 09:37:40 +0200 Subject: [PATCH 321/378] Answer foreign-position type asks from the scope's state uniformly Replaces the repriceVariables flag with the model it was approximating: when a result is asked for its type on a scope, the scope - not the result - owns the answer for every expression it is authoritative about: variables it knows (including $this and parameters, via the variable read), and any expression it tracks a holder for. Only expressions the scope knows nothing about answer from the memoized walk-position type. This is getStateType's contract applied at the ExpressionResult boundary; the old gate excluded variables only because the raw holder read behind it could not answer $this or parameters. The one consumer that genuinely wanted a position-fixed value - the foreach-by-ref write-through, which rebuilds the iteratee with the value variable's latest type - was relying on the stale stored-result read to mean "the iteratee at foreach entry". It now captures that entry type explicitly in the intertwined expression; a live read would union transient mid-iteration value states into the array, which is the loop convergence's job. Eighteen more known-red tests pass, including the rest of the match-arm cluster: rules asking about synthetic comparisons on arm-narrowed scopes now see the narrowed subject through any expression shape, not just variables. --- src/Analyser/ExprHandler/BinaryOpHandler.php | 14 +++---- src/Analyser/ExpressionResult.php | 40 +++++++++++--------- src/Analyser/MutatingScope.php | 6 ++- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 49dbe5638de..ccd2374ec1b 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -129,16 +129,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // arm-narrowed scope) it is the asking scope, whose tracked // narrowing the stored operand results predate $flavouredScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; - // operands are re-priced from this result's own beforeScope - // (with variables opted in): for the main walk that is the walk - // position, but for an on-demand walk of a synthetic (a rule - // asking about Identical($x, ...) on an arm-narrowed scope) it - // carries narrowing the stored operand results predate + // operands are re-priced from this result's own beforeScope: + // for the main walk that is the walk position, but for an + // on-demand walk of a synthetic (a rule asking about + // Identical($x, ...) on an arm-narrowed scope) it carries + // narrowing the stored operand results predate if ($e === $expr->left) { - return $leftResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted, true); + return $leftResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted); } if ($e === $expr->right) { - return $rightResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted, true); + return $rightResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted); } return $nodeScopeResolver->readTypeOfMaybeStored($e, $flavouredScope); diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index e178cc837b0..ef62ab88b3b 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -396,30 +396,34 @@ public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool * consumers - isset/empty/?? chain folding and the stored-result read in * NodeScopeResolver - everything else reads getType()/getNativeType(). */ - public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes, bool $repriceVariables = false): Type + public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type { $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; - if ($this->type === null && $this->hasTrackedExpressionType($readScope)) { - return $readScope->getTrackedExpressionType($this->expr); + if ($this->type === null && $this->isScopeAuthoritative($readScope)) { + return $readScope->getStateType($this->expr); } - // rule-facing asks re-price a variable from the given scope: it may - // carry narrowing this result's walk position predates (a rule asking - // about a synthetic comparison on an arm-narrowed scope). The walk's - // own consumers (stored-result reads, chain folds) keep the memoized - // walk-position type - their scopes can also be WIDER (merged, loop - // converged) than the position the value flowed from. - if ( - $repriceVariables - && $this->type === null - && $this->expr instanceof Expr\Variable - && is_string($this->expr->name) - && !$readScope->hasVariableType($this->expr->name)->no() - ) { - return $readScope->getVariableType($this->expr->name); + return $this->resolveOwnType($useNativeTypes); + } + + /** + * Whether the given scope, not this result, owns the answer to "what is + * this expression here": narrowable expressions the scope knows (variables + * including $this and parameters, tracked fetches) and any expression the + * scope tracks a holder for (ensured non-nullability, remembered values). + * Evaluating this result's expression at a foreign position must read + * those from that position's state - the memoized walk-position type + * predates whatever narrowing or invalidation the scope carries. + */ + private function isScopeAuthoritative(MutatingScope $scope): bool + { + if ($this->expr instanceof Expr\Variable) { + return is_string($this->expr->name) && !$scope->hasVariableType($this->expr->name)->no(); } - return $this->resolveOwnType($useNativeTypes); + return !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes(); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fc2063098b0..8c4777cf551 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2666,9 +2666,13 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee // ($type = 'foo' invalidates this expression, same as OriginalForeachKeyExpr). $scope = $scope->assignExpression(new OriginalForeachValueExpr($valueName), $valueType, $nativeValueType); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + // the write-through rebuilds the iteratee AT FOREACH ENTRY with the + // value variable's latest type - captured here, not read live: a + // live read would union the transient mid-iteration value states + // into the array (the loop convergence owns cross-iteration merging) $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr( - $iteratee, + new NativeTypeExpr($iterateeType, $nativeIterateeType), new NativeTypeExpr( $originalScope->getIterableKeyType($iterateeType), $originalScope->getIterableKeyType($nativeIterateeType), From 52b759e2165f4bf3c6693ccd7a3c2d0cc17fb078 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 09:58:33 +0200 Subject: [PATCH 322/378] Read nullsafe receivers from before the enclosing ensure's device types An isset/empty/?? ensure writes non-null device types into scope state so nested fetches walk without spurious possibly-null noise - which made a nullsafe operator inside the ensured subject read its receiver as non-null and drop the short-circuit null from its own type (isset($this->get()?->aaa) saw int instead of int|null, so the isset rules reported "not nullable" instead of "unnecessary ?->"). NonNullabilityHelper now keeps a stack of the ensures in effect during the walk, recording the pre-device types its results already carried; the nullsafe handlers consult it for the receiver's real type. The device types keep doing their job for everything else. --- .../Helper/NonNullabilityHelper.php | 68 ++++++++++++++++++- .../ExprHandler/NullsafeMethodCallHandler.php | 8 ++- .../NullsafePropertyFetchHandler.php | 8 ++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index f56dffac680..19da0087576 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,15 +11,75 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; +use PHPStan\Node\Printer\ExprPrinter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_pop; +use function count; #[AutowiredService] final class NonNullabilityHelper { + /** + * The ensures currently in effect during the walk, innermost last. An + * ensure writes non-null "device" types into the scope so nested fetches + * walk without spurious possibly-null noise - indistinguishable from + * genuine narrowing in scope state. Handlers whose semantics depend on an + * expression's REAL nullability (a nullsafe operator's short-circuit) + * consult this stack for the pre-device type instead. + * + * @var list> + */ + private array $activeEnsures = []; + + public function __construct(private ExprPrinter $exprPrinter) + { + } + + /** + * The pre-device type an active ensure saved for this expression, or null + * when no ensure covers it. + */ + public function getActiveEnsuredOriginalType(Expr $expr, bool $native): ?Type + { + if ($this->activeEnsures === []) { + return null; + } + + $key = $this->exprPrinter->printExpr($expr); + for ($i = count($this->activeEnsures) - 1; $i >= 0; $i--) { + if (isset($this->activeEnsures[$i][$key])) { + return $this->activeEnsures[$i][$key][$native ? 1 : 0]; + } + } + + return null; + } + public function ensureShallowNonNullability(MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + { + $result = $this->doEnsureShallowNonNullability($scope, $originalScope, $exprToSpecify); + $this->pushActiveEnsure($result); + + return $result; + } + + private function pushActiveEnsure(EnsuredNonNullabilityResult $result): void + { + $originals = []; + foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { + $originals[$this->exprPrinter->printExpr($specifiedExpression->getExpression())] = [ + $specifiedExpression->getOriginalType(), + $specifiedExpression->getOriginalNativeType(), + ]; + } + $this->activeEnsures[] = $originals; + } + + private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult { // the expression has not been processed into the storage yet (this runs // before processExprNode) - derive its current type from the scope's @@ -86,14 +146,17 @@ public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredN $specifiedExpressions = []; $originalScope = $scope; $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $result = $this->doEnsureShallowNonNullability($scope, $originalScope, $expr); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } return $result->getScope(); }, false); - return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + $result = new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + $this->pushActiveEnsure($result); + + return $result; } /** @@ -101,6 +164,7 @@ public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredN */ public function revertNonNullability(MutatingScope $scope, array $specifiedExpressions): MutatingScope { + array_pop($this->activeEnsures); foreach ($specifiedExpressions as $specifiedExpressionResult) { if ($specifiedExpressionResult->getCertainty()->no()) { $scope = $scope->invalidateExpression($specifiedExpressionResult->getExpression()); diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 3555982ed5c..ea3082c18fb 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -59,8 +59,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's real (possibly null) type, captured before it is ensured // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. - $receiverType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); - $receiverNativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); + // an enclosing isset/empty/?? ensure may have deviced the receiver + // non-null in scope state so nested fetches walk quietly; the + // short-circuit decision needs the receiver's REAL type from before + // that device + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); // carry the receiver type to NullsafeMethodCallRule so it reads it from here // instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 93a3e15732e..1af05ff915a 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -57,8 +57,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's real (possibly null) type, captured before it is ensured // non-null below: the short-circuit decision needs to know it can be null, // which reading the ensured-non-null result would hide. - $receiverType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); - $receiverNativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); + // an enclosing isset/empty/?? ensure may have deviced the receiver + // non-null in scope state so nested fetches walk quietly; the + // short-circuit decision needs the receiver's REAL type from before + // that device + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); // carry the receiver type to NullsafePropertyFetchRule so it reads it from // here instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); From 4eb33cf6425a3a1186749d52c36007307b951016 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 10:36:09 +0200 Subject: [PATCH 323/378] Release each file's captured results; drop dead filterBy* scope caches PHPStan runs with gc_disable(), so anything reachable stays allocated. The Ternary/Match handlers capture operand results in WeakMaps keyed by AST nodes - and the parser cache retains ASTs across files, so those entries never died, holding every analysed file's result graph (callbacks, scopes, types) until the process ended. NodeScopeResolver now resets such per-file state at the start of each file's analysis; measured on self-analysis, live results at file boundaries drop to zero and make phpstan's used memory goes from 2.68 GB to 2.38 GB. The truthyScopes/falseyScopes caches on MutatingScope memoized whole filterBy*() result scopes per expression; instrumentation showed zero hits across self-analysis - the engine reads branch scopes from expression results now, and rule-facing filterBy*() calls do not repeat per scope. Deleted, along with their propagation in the scope-copying factories. --- src/Analyser/ExprHandler/MatchHandler.php | 8 ++++- src/Analyser/ExprHandler/TernaryHandler.php | 8 ++++- src/Analyser/MutatingScope.php | 34 ++------------------- src/Analyser/NodeScopeResolver.php | 18 +++++++++++ src/Analyser/PerFileAnalysisResettable.php | 17 +++++++++++ 5 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 src/Analyser/PerFileAnalysisResettable.php diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 6981c72b3ee..49bf4f0b2cc 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -24,6 +24,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; @@ -54,12 +55,17 @@ * @implements ExprHandler */ #[AutowiredService] -final class MatchHandler implements ExprHandler +final class MatchHandler implements ExprHandler, PerFileAnalysisResettable { /** @var WeakMap> */ private WeakMap $capturedArmResults; + public function resetFileAnalysisState(): void + { + $this->capturedArmResults = new WeakMap(); + } + public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 5e777fbd191..555988dd1e6 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -16,6 +16,7 @@ use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; @@ -31,12 +32,17 @@ * @implements ExprHandler */ #[AutowiredService] -final class TernaryHandler implements ExprHandler +final class TernaryHandler implements ExprHandler, PerFileAnalysisResettable { /** @var WeakMap */ private WeakMap $capturedResults; + public function resetFileAnalysisState(): void + { + $this->capturedResults = new WeakMap(); + } + public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8c4777cf551..54cd4d51661 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -150,12 +150,6 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter /** @var array */ private array $pricedSpecifiedExprTypePairs = []; - /** @var array */ - private array $truthyScopes = []; - - /** @var array */ - private array $falseyScopes = []; - private ?self $fiberScope = null; /** @var non-empty-string|null */ @@ -2762,8 +2756,6 @@ public function enterExpressionAssign(Expr $expr, bool $isPlainWrite = true): se $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2793,8 +2785,6 @@ public function exitExpressionAssign(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2854,8 +2844,6 @@ public function setAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2885,8 +2873,6 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -3509,16 +3495,9 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self */ public function filterByTruthyValue(Expr $expr): self { - $exprString = $this->getNodeKey($expr); - if (array_key_exists($exprString, $this->truthyScopes)) { - return $this->truthyScopes[$exprString]; - } - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - $scope = $this->applySpecifiedTypes($specifiedTypes); - $this->truthyScopes[$exprString] = $scope; - return $scope; + return $this->applySpecifiedTypes($specifiedTypes); } /** @@ -3526,16 +3505,9 @@ public function filterByTruthyValue(Expr $expr): self */ public function filterByFalseyValue(Expr $expr): self { - $exprString = $this->getNodeKey($expr); - if (array_key_exists($exprString, $this->falseyScopes)) { - return $this->falseyScopes[$exprString]; - } - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - $scope = $this->applySpecifiedTypes($specifiedTypes); - $this->falseyScopes[$exprString] = $scope; - return $scope; + return $this->applySpecifiedTypes($specifiedTypes); } /** @@ -3874,8 +3846,6 @@ public function exitFirstLevelStatements(): self $this->afterExtractCall, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; $this->scopeOutOfFirstLevelStatement = $scope; return $scope; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 93d0749a35d..ebaa6935a23 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -291,6 +291,22 @@ public function setAnalysedFiles(array $files): void $this->analysedFiles = array_fill_keys($files, true); } + /** + * Releases the previous file's node-keyed captures: the parser cache + * retains ASTs, so WeakMap entries keyed by their nodes never die on + * their own and would hold that file's whole result graph alive. + */ + public function resetPerFileAnalysisState(): void + { + foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandlerService) { + if (!$exprHandlerService instanceof PerFileAnalysisResettable) { + continue; + } + + $exprHandlerService->resetFileAnalysisState(); + } + } + /** * @api * @param Node[] $nodes @@ -310,6 +326,8 @@ public function processNodes( } } + $this->resetPerFileAnalysisState(); + $expressionResultStorage = new ExpressionResultStorage(); $scope->pushExpressionResultStorage($expressionResultStorage); try { diff --git a/src/Analyser/PerFileAnalysisResettable.php b/src/Analyser/PerFileAnalysisResettable.php new file mode 100644 index 00000000000..656c1c3be85 --- /dev/null +++ b/src/Analyser/PerFileAnalysisResettable.php @@ -0,0 +1,17 @@ + Date: Tue, 7 Jul 2026 10:59:32 +0200 Subject: [PATCH 324/378] Release resolved typeCallbacks, share the empty specify closure, move the closure-type cache to a per-file WeakMap --- src/Analyser/ExprHandler/ArrayHandler.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 10 ++-- src/Analyser/ExprHandler/AssignOpHandler.php | 2 +- .../Helper/ClosureTypeResolver.php | 26 ++++++++-- .../Helper/ImplicitToStringCallHelper.php | 4 +- src/Analyser/ExprHandler/PipeHandler.php | 2 +- src/Analyser/ExprHandler/ScalarHandler.php | 2 +- src/Analyser/ExprHandler/VariableHandler.php | 2 +- .../Virtual/ExistingArrayDimFetchHandler.php | 2 +- .../SetExistingOffsetValueTypeExprHandler.php | 2 +- .../Virtual/SetOffsetValueTypeExprHandler.php | 2 +- .../Virtual/UnsetOffsetExprHandler.php | 2 +- src/Analyser/ExpressionResult.php | 47 +++++++++++++++++-- src/Analyser/NodeScopeResolver.php | 18 +++---- src/Analyser/PerFileAnalysisResettable.php | 2 + src/Analyser/SpecifiedTypes.php | 16 +++++++ .../ValidateServiceTagsExtension.php | 2 + .../ArrayMapFunctionReturnTypeExtension.php | 3 +- 18 files changed, 111 insertions(+), 35 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 094bea8a0c4..59827568895 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -125,7 +125,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $type; }, - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 864aa46687f..da0ddbd1fbc 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -174,7 +174,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $result->getNativeType() : $result->getType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); }, true, @@ -690,7 +690,7 @@ public function processAssignVar( throwPoints: [], impurePoints: [], typeCallback: static fn (): Type => new NeverType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); } else { @@ -714,7 +714,7 @@ public function processAssignVar( throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope)), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); $scope = $result->getScope(); @@ -1069,7 +1069,7 @@ public function processAssignVar( $context, fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(),), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(),), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -1169,7 +1169,7 @@ public function processAssignVar( // stored where processAssignVar is called return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(),); + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(),); } private function createArrayDimFetchConditionalExpressionHolder( diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 989ae359c64..40a8321a4d6 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -214,7 +214,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $exprResult->getThrowPoints(), $exprResult->getImpurePoints(), typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ed357062f65..2b3c0176c24 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\ThrowPoint; @@ -45,6 +46,7 @@ use PHPStan\Type\VerbosityLevel; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; +use WeakMap; use function array_key_exists; use function array_keys; use function implode; @@ -55,15 +57,31 @@ use function is_string; #[AutowiredService] -final class ClosureTypeResolver +final class ClosureTypeResolver implements PerFileAnalysisResettable { private static int $resolveClosureTypeDepth = 0; + /** + * Per-context resolved closure types, keyed by the closure node. Node + * attributes would persist on the parser cache's retained ASTs after the + * file's analysis ends - a WeakMap reset per file releases the Types and + * throw/impure points with the rest of the file's result graph. + * + * @var WeakMap> + */ + private WeakMap $cachedTypes; + public function __construct( private NodeScopeResolver $nodeScopeResolver, ) { + $this->cachedTypes = new WeakMap(); + } + + public function resetFileAnalysisState(): void + { + $this->cachedTypes = new WeakMap(); } /** @@ -102,7 +120,7 @@ public function getClosureType( ); } - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cachedTypes = $this->cachedTypes[$expr] ?? []; $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); if (array_key_exists($cacheKey, $cachedTypes)) { return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); @@ -778,7 +796,7 @@ private function assembleClosureType( $throwPointsForClosureType = array_map(static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit() ? SimpleThrowPoint::createExplicit($throwPoint->getType(), $throwPoint->canContainAnyThrowable()) : SimpleThrowPoint::createImplicit(), $throwPoints); $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cachedTypes = $this->cachedTypes[$expr] ?? []; $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); $cachedTypes[$cacheKey] = [ 'returnType' => $returnType, @@ -787,7 +805,7 @@ private function assembleClosureType( 'invalidateExpressions' => $invalidateExpressions, 'usedVariables' => $usedVariables, ]; - $expr->setAttribute('phpstanCachedTypes', $cachedTypes); + $this->cachedTypes[$expr] = $cachedTypes; $mustUseReturnValue = TrinaryLogic::createNo(); foreach ($expr->attrGroups as $attrGroup) { diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index eb37997e56b..750cebac6f0 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -58,7 +58,7 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver throwPoints: [], impurePoints: [], typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } @@ -98,7 +98,7 @@ public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver throwPoints: $throwPoints, impurePoints: $impurePoints, typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 860f479f8f8..53cfc2582eb 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -90,7 +90,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); } diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index a27e0f04ec4..1154a1d4984 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 9e6996275a4..3f21b0953a7 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -92,7 +92,7 @@ private function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScope throwPoints: [], impurePoints: [], typeCallback: static fn (): Type => $constantString, - specifyTypesCallback: static fn (): SpecifiedTypes => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); $specifiedTypes = $this->identicalNarrowingHelper->specifyIdentical( $nodeScopeResolver, diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 59768ebeecc..e4d333f0361 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $arrayDimFetchResult->getNativeType() : $arrayDimFetchResult->getType()), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index c83e0dad9a5..df9abdc432d 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -56,7 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()), ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), ), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index 2ee2f3de31a..4ae3ada2650 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $dimResult !== null ? ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()) : null, ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), ), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index d08f64cf232..badfc48069a 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -52,7 +52,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->unsetOffset(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType())), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index ef62ab88b3b..dbfdb793a68 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -213,7 +213,7 @@ public function getType(): Type } } - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { + if ($this->hasOwnLazyResolution() && !$this->hasTrackedExpressionType($this->beforeScope)) { return $this->cachedType = $this->resolveOwnType(false); } @@ -233,7 +233,7 @@ public function getNativeType(): Type return $this->cachedNativeType; } - if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { + if ($this->hasOwnLazyResolution() && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { return $this->cachedNativeType = $this->resolveOwnType(true); } @@ -259,21 +259,49 @@ private function resolveOwnRawType(bool $nativeTypesPromoted): Type if ($this->nativeType !== null) { return $this->nativeType; } + if ($this->resolvedNativeType !== null) { + return $this->resolvedNativeType; + } if ($this->typeCallback === null) { throw new ShouldNotHappenException(); } - return $this->resolvedNativeType ??= TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + $resolvedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + $this->resolvedNativeType = $resolvedNativeType; + $this->releaseTypeCallbackIfResolved(); + + return $resolvedNativeType; } if ($this->type !== null) { return $this->type; } + if ($this->resolvedType !== null) { + return $this->resolvedType; + } if ($this->typeCallback === null) { throw new ShouldNotHappenException(); } - return $this->resolvedType ??= TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); + $resolvedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); + $this->resolvedType = $resolvedType; + $this->releaseTypeCallbackIfResolved(); + + return $resolvedType; + } + + /** + * Once both flavours are memoized the callback can never be invoked again - + * dropping it releases its captured environment (child results, intermediate + * scopes) for refcount collection while the file is still being analysed. + */ + private function releaseTypeCallbackIfResolved(): void + { + if ($this->resolvedType === null || $this->resolvedNativeType === null) { + return; + } + + $this->typeCallback = null; } /** @@ -335,7 +363,16 @@ private function hasTrackedExpressionType(MutatingScope $scope): bool */ public function canResolveOwnType(): bool { - return $this->type !== null || $this->typeCallback !== null; + return $this->type !== null || $this->hasOwnLazyResolution(); + } + + /** + * True while the typeCallback is alive or after it was released because both + * flavour memos are filled - either way the result answers its own type. + */ + private function hasOwnLazyResolution(): bool + { + return $this->typeCallback !== null || $this->resolvedType !== null; } /** Evaluates this expression's narrowing on the given scope. */ diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ebaa6935a23..0479f31371d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -298,12 +298,12 @@ public function setAnalysedFiles(array $files): void */ public function resetPerFileAnalysisState(): void { - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandlerService) { - if (!$exprHandlerService instanceof PerFileAnalysisResettable) { + foreach ($this->container->getServicesByTag(PerFileAnalysisResettable::TAG) as $resettableService) { + if (!$resettableService instanceof PerFileAnalysisResettable) { continue; } - $exprHandlerService->resetFileAnalysisState(); + $resettableService->resetFileAnalysisState(); } } @@ -3095,7 +3095,7 @@ private function processExprNodeInternal( // the first-class callable closure type lives on the *CallableNode // result; delegate so getType() of the original CallLike answers from it typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $newExprResult->getNativeType() : $newExprResult->getType()), - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); $this->storeExpressionResult($storage, $expr, $expressionResult); return $expressionResult; @@ -3630,7 +3630,7 @@ public function processArrowFunctionNode( return new ProcessArrowFunctionResult( $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(),), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(),), $arrowFunctionScope, $closureTypeThrowPoints, $closureTypeImpurePoints, @@ -4232,7 +4232,7 @@ public function processArgs( ), nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), typeCallback: null, - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); $uses = []; @@ -4326,7 +4326,7 @@ public function processArgs( ), nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), typeCallback: null, - specifyTypesCallback: static fn () => new SpecifiedTypes(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); } else { $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; @@ -4498,7 +4498,7 @@ public function processArgs( return new ArgsResult( $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(),), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(),), $resolvedAcceptor, $argResults, ); @@ -4805,7 +4805,7 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora ExpressionContext::createDeep(), fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], typeCallback: static fn () => new MixedType(), - specifyTypesCallback: static fn () => new SpecifiedTypes(),), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(),), false, ); } diff --git a/src/Analyser/PerFileAnalysisResettable.php b/src/Analyser/PerFileAnalysisResettable.php index 656c1c3be85..b35c4dbd3bd 100644 --- a/src/Analyser/PerFileAnalysisResettable.php +++ b/src/Analyser/PerFileAnalysisResettable.php @@ -12,6 +12,8 @@ interface PerFileAnalysisResettable { + public const TAG = 'phpstan.perFileAnalysisResettable'; + public function resetFileAnalysisState(): void; } diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 35d1e1baaad..0fd9dd63a1d 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use Closure; use PhpParser\Node\Expr; use PHPStan\Type\NeverType; use PHPStan\Type\Type; @@ -12,6 +13,9 @@ final class SpecifiedTypes { + /** @var (Closure(TypeSpecifierContext, bool): self)|null */ + private static ?Closure $emptySpecifyCallback = null; + private bool $overwrite = false; /** @var array */ @@ -61,6 +65,18 @@ public function __construct( { } + /** + * A shared no-narrowing specify callback for results whose expression never + * narrows anything (literals, virtual write nodes) - one process-wide + * closure instead of one allocation per created ExpressionResult. + * + * @return Closure(TypeSpecifierContext, bool): self + */ + public static function emptySpecifyCallback(): Closure + { + return self::$emptySpecifyCallback ??= static fn (): self => new self(); + } + /** * Normally, $sureTypes in truthy context are used to intersect with the pre-existing type. * And $sureNotTypes are used to remove type from the pre-existing type. diff --git a/src/DependencyInjection/ValidateServiceTagsExtension.php b/src/DependencyInjection/ValidateServiceTagsExtension.php index 6412ba85f97..5a99aae4611 100644 --- a/src/DependencyInjection/ValidateServiceTagsExtension.php +++ b/src/DependencyInjection/ValidateServiceTagsExtension.php @@ -6,6 +6,7 @@ use Override; use PhpParser\NodeVisitor; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\IgnoreErrorExtension; use PHPStan\Analyser\ResultCache\ResultCacheMetaExtension; use PHPStan\Analyser\TypeSpecifierFactory; @@ -121,6 +122,7 @@ final class ValidateServiceTagsExtension extends CompilerExtension DiagnoseExtension::class => DiagnoseExtension::EXTENSION_TAG, IgnoreErrorExtension::class => IgnoreErrorExtension::EXTENSION_TAG, ExprHandler::class => ExprHandler::EXTENSION_TAG, + PerFileAnalysisResettable::class => PerFileAnalysisResettable::TAG, ]; /** diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index 75766e35fd9..a9766d7ceba 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -172,8 +172,9 @@ private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callb [$argType], isList: TrinaryLogic::createYes(), ); + // the resolved-closure-type cache is keyed by node identity + // (ClosureTypeResolver::$cachedTypes), so the clone starts uncached $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); - $clone->setAttribute('phpstanCachedTypes', []); return $scope->getType($clone)->getCallableParametersAcceptors($scope)[0]->getReturnType(); } From 31560e324e0e65a4019c6b07a19b5ee702d4b506 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 11:11:28 +0200 Subject: [PATCH 325/378] Process function and method bodies against a per-body result storage released after their return-statements rules --- src/Analyser/NodeScopeResolver.php | 205 ++++++++++++++++------------- 1 file changed, 113 insertions(+), 92 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0479f31371d..25e091dc4f4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -938,49 +938,58 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $functionImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $functionScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $functionImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; - } + // the body's results live in a per-body storage released right after + // the FunctionReturnStatementsNode rules ran - see the ClassMethod + // branch for the reasoning + $bodyStorage = $storage->duplicate(); + $scope->pushExpressionResultStorage($bodyStorage); + try { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $functionScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $functionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { + return; + } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel())->toPublic(); - $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( - $stmt, - $gatheredReturnStatements, - $gatheredYieldStatements, - $statementResult, - $executionEnds, - array_merge($statementResult->getImpurePoints(), $functionImpurePoints), - $functionReflection, - ), $functionScope, $storage); - if (!$scope->isInAnonymousFunction()) { - $this->processPendingFibers($storage); + $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $functionImpurePoints), + $functionReflection, + ), $functionScope, $bodyStorage); + if (!$scope->isInAnonymousFunction()) { + $this->processPendingFibers($bodyStorage); + } + } finally { + $scope->popExpressionResultStorage(); } // declaring the function defines it in global state, so a negative @@ -1093,62 +1102,74 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $methodScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - if ( - $node->getPropertyFetch() instanceof Expr\PropertyFetch - && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection - && $scope->getFunction()->getDeclaringClass()->hasConstructor() - && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($this->readTypeOfMaybeStored($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null - ) { + // the body's results live in a per-body storage released right + // after the MethodReturnStatementsNode rules ran: later asks about + // body expressions (e.g. class-level rules pricing gathered nodes) + // go through the on-demand bridge, so keeping the results for the + // rest of the file would only pin the body's whole result graph + // (callbacks, scopes, types) at no benefit + $bodyStorage = $storage->duplicate(); + $scope->pushExpressionResultStorage($bodyStorage); + try { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $methodScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + if ( + $node->getPropertyFetch() instanceof Expr\PropertyFetch + && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection + && $scope->getFunction()->getDeclaringClass()->hasConstructor() + && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() + && TypeUtils::findThisType($this->readTypeOfMaybeStored($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null + ) { + return; + } + $methodImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { return; } - $methodImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; - } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel())->toPublic(); - $methodReflection = $methodScope->getFunction(); - if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { - throw new ShouldNotHappenException(); - } + $methodReflection = $methodScope->getFunction(); + if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } - $this->callNodeCallback($nodeCallback, new MethodReturnStatementsNode( - $stmt, - $gatheredReturnStatements, - $gatheredYieldStatements, - $statementResult, - $executionEnds, - array_merge($statementResult->getImpurePoints(), $methodImpurePoints), - $classReflection, - $methodReflection, - ), $methodScope, $storage); + $this->callNodeCallback($nodeCallback, new MethodReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $methodImpurePoints), + $classReflection, + $methodReflection, + ), $methodScope, $bodyStorage); + } finally { + $scope->popExpressionResultStorage(); + } if ($isConstructor) { $finalScope = null; From b42c9dc81bc767d610ac6e7c5e3ae6fc9b92ad26 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 11:29:54 +0200 Subject: [PATCH 326/378] Read ternary branch results on their processing scopes --- src/Analyser/ExprHandler/TernaryHandler.php | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 555988dd1e6..2068403b000 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -93,7 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $hasYield = $hasYield || $ifResult->hasYield(); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifResult->getType(); + $ifTrueType = $ifResult->getTypeOnScope($ifProcessingScope, false); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -115,7 +115,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $elseResult->getType(); + $ifFalseType = $elseResult->getTypeOnScope($elseProcessingScope, false); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; @@ -138,13 +138,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, // the branches were processed on the cond-truthy/cond-falsey scopes // including the condition's side effects - those captured scopes - // are the evaluation points, no re-walk needed - typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $nodeScopeResolver): Type { + // are the evaluation points, no re-walk needed. Reading the branch + // results ON those scopes matters when processExprNode answered a + // branch from a stored result (an on-demand ternary whose branches + // are already-walked real nodes): the stored walk-position type + // predates the condition's narrowing the branch scope carries. + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver): Type { if ($nativeTypesPromoted) { $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); } $booleanConditionType = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); - $elseType = ($nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType()); + $elseType = $elseResult->getTypeOnScope($elseProcessingScope, $nativeTypesPromoted); if ($expr->if === null || $ifResult === null) { // short-ternary truthy value: the condition read on its own truthy scope // is a different scope than its own, so reprocess it there. @@ -163,7 +167,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } - $ifType = ($nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType()); + $ifType = $ifResult->getTypeOnScope($ifProcessingScope, $nativeTypesPromoted); if ($booleanConditionType->isTrue()->yes()) { return $ifType; } @@ -177,7 +181,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $elseType, ); }, - specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { $s = $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; if ($expr->cond instanceof Ternary || $context->null()) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); @@ -217,7 +221,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex }; }; $elseTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $elseResult->getSpecifiedTypesForScope($scope, $ctx); - $elseType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType(); + $elseType = static fn (bool $nativeTypesPromoted): Type => $elseResult->getTypeOnScope($elseProcessingScope, $nativeTypesPromoted); // the decomposition's branch scopes are the operand walks' own // memoized branch scopes (the evaluation points), not ask-derived; @@ -248,7 +252,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // left disjunct: cond && if $aNode = new BooleanAnd($expr->cond, $expr->if); $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); - $ifType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType(); + $ifType = static fn (bool $nativeTypesPromoted): Type => $ifResult->getTypeOnScope($ifProcessingScope, $nativeTypesPromoted); $ifFalseyOnCondTruthyScope = static fn (): MutatingScope => $ifResult->getFalseyScope(); $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( $nodeScopeResolver, From de5888f4781ca1284258326735a649b58a91e355 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 11:38:02 +0200 Subject: [PATCH 327/378] Expect the flavour-pure native type in the match arm always-true message --- tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 35a91c5a751..8840cb5a7f4 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -346,7 +346,11 @@ public function testBug8937(): void 23, ], [ - 'Call to function is_array() with non-empty-array will always evaluate to true.', + // the reported type is the native flavour (treatPhpDocTypesAsCertain=false); + // natively $v is array, so the arm-2 falsey leaves non-empty-array - the + // old value type was a normalize() artifact leaking the PHPDoc + // flavour into the native holders + 'Call to function is_array() with non-empty-array will always evaluate to true.', 24, ], ]); From 5acc28921303475571784dc2cdd2fe17280790f3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 11:58:37 +0200 Subject: [PATCH 328/378] Revert "Expect the flavour-pure native type in the match arm always-true message" This reverts commit 6fb2b5723d5256583560bdfaf3bff562e93b00e1. --- tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 8840cb5a7f4..35a91c5a751 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -346,11 +346,7 @@ public function testBug8937(): void 23, ], [ - // the reported type is the native flavour (treatPhpDocTypesAsCertain=false); - // natively $v is array, so the arm-2 falsey leaves non-empty-array - the - // old value type was a normalize() artifact leaking the PHPDoc - // flavour into the native holders - 'Call to function is_array() with non-empty-array will always evaluate to true.', + 'Call to function is_array() with non-empty-array will always evaluate to true.', 24, ], ]); From 5e6fb4b30f61a746c342abe7d28622e6941ea44c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 12:08:13 +0200 Subject: [PATCH 329/378] Collect branch-union candidates from sureNot narrowings, skip template-typed subjects --- src/Analyser/DisjunctionBranchUnionAugment.php | 13 ++++++++++++- .../Helper/ConditionalExpressionHolderHelper.php | 12 ++++++++++++ .../Rules/Classes/ImpossibleInstanceOfRuleTest.php | 1 - 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Analyser/DisjunctionBranchUnionAugment.php b/src/Analyser/DisjunctionBranchUnionAugment.php index fe15bbfd312..d02849bbeb8 100644 --- a/src/Analyser/DisjunctionBranchUnionAugment.php +++ b/src/Analyser/DisjunctionBranchUnionAugment.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; /** * The either-branch union recovery: an expression the exact merge left @@ -39,6 +40,13 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes } $originalType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + // re-pinning eagerly priced branch forms of a template-typed subject + // stacks the template inside its own bound (`T of T of ...` - the + // pin intersects with the declared template); its narrowing already + // flows through the operands' exact merge + if (TypeUtils::containsTemplateType($originalType)) { + continue; + } if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { continue; } @@ -48,7 +56,10 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes } $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($originalType)) { + // a union that covers the whole original type gains no narrowing - + // pinning it would only stack a redundant intersection on the + // expression (e.g. re-wrapping a template type in its own bound) + if ($unionType->isSuperTypeOf($originalType)->yes()) { continue; } diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 5ad6a640ec8..131af11e886 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -59,6 +59,18 @@ public function buildBranchUnionAugment( foreach ($rightTypes->getSureTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } + // sureNot entries constrain their branch too - the old normalize() + // converted them to sure entries before candidates were collected, so a + // sureNot-only narrowing (e.g. the truthy of a bool variable) must also + // contribute its subject. The branch-scope reads below price the subject + // on each filtered scope, where an impossible branch (a holder-fixpoint + // contradiction) collapses to never and drops out of the union. + foreach ($leftTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } + foreach ($rightTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } $existingSureTypes = $types->getSureTypes(); $existingAlternativeTypes = $types->getAlternativeTypes(); diff --git a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php index c57d273694a..e701bf91f4e 100644 --- a/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php @@ -149,7 +149,6 @@ public function testInstanceof(): void [ 'Instanceof between *NEVER* and ImpossibleInstanceOf\Foo will always evaluate to false.', 234, - $tipText, ], [ 'Instanceof between ImpossibleInstanceOf\Bar&ImpossibleInstanceOf\Foo and ImpossibleInstanceOf\Foo will always evaluate to true.', From 71a798f31b0be9d42b45d529a20c30b00c9d0256 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 12:58:41 +0200 Subject: [PATCH 330/378] Revert maybe-existing property promotion to its declared type The isset-chain descriptor now recovers optional-property types in isset()/?? positions itself, so the global promotion only changed bare reads away from ErrorType. --- src/Analyser/MutatingScope.php | 13 ------------- tests/PHPStan/Analyser/data/bug-10302.php | 2 +- .../Analyser/nsrt/bug-10302-trait-extends.php | 2 +- tests/PHPStan/Analyser/nsrt/properties.php | 2 +- tests/PHPStan/Analyser/nsrt/type-aliases.php | 4 ++-- tests/PHPStan/Analyser/nsrt/union-intersection.php | 2 +- .../StrictComparisonOfDifferentTypesRuleTest.php | 5 ----- tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php | 4 ++++ 8 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 54cd4d51661..caf54cf2032 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -66,7 +66,6 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetValueType; -use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Accessory\OversizedArrayType; use PHPStan\Type\ArrayType; @@ -4904,13 +4903,6 @@ public function getInstancePropertyReflection(Type $typeWithProperty, string $pr if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasInstanceProperty($propertyName)->yes()); } - if ($typeWithProperty->hasInstanceProperty($propertyName)->maybe()) { - // an optional property (e.g. object{foo?: int}) only maybe exists, so its - // reflection is not directly queryable. Asserting its existence via - // HasPropertyType makes it yes and exposes the declared type - which is the - // only useful type for `$object->foo`; whether it exists is a separate check. - $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); - } if (!$typeWithProperty->hasInstanceProperty($propertyName)->yes()) { return null; } @@ -4924,11 +4916,6 @@ public function getStaticPropertyReflection(Type $typeWithProperty, string $prop if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasStaticProperty($propertyName)->yes()); } - if ($typeWithProperty->hasStaticProperty($propertyName)->maybe()) { - // mirror getInstancePropertyReflection(): assert a maybe-existing property - // so its declared type becomes queryable - $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); - } if (!$typeWithProperty->hasStaticProperty($propertyName)->yes()) { return null; } diff --git a/tests/PHPStan/Analyser/data/bug-10302.php b/tests/PHPStan/Analyser/data/bug-10302.php index 29389436993..9cb45b51b6a 100644 --- a/tests/PHPStan/Analyser/data/bug-10302.php +++ b/tests/PHPStan/Analyser/data/bug-10302.php @@ -51,5 +51,5 @@ function (BatchAwareWithoutAllowDynamicProperties $b): void { $result = $b->busy; // @phpstan-ignore-line - assertType('mixed', $result); + assertType('*ERROR*', $result); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php b/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php index 57e185e85e7..6489de2dcc8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10302-trait-extends.php @@ -13,7 +13,7 @@ function test(): void { assertType('int', $this->x); assertType('string', $this->y); - assertType('mixed', $this->z); + assertType('*ERROR*', $this->z); } } diff --git a/tests/PHPStan/Analyser/nsrt/properties.php b/tests/PHPStan/Analyser/nsrt/properties.php index d907eaffdc1..ea3d7ecf5f0 100644 --- a/tests/PHPStan/Analyser/nsrt/properties.php +++ b/tests/PHPStan/Analyser/nsrt/properties.php @@ -146,7 +146,7 @@ public function doFoo() assertType('PropertiesNamespace\Lorem', $this->objectRelative); assertType('SomeOtherNamespace\Ipsum', $this->objectFullyQualified); assertType('SomeNamespace\Amet', $this->objectUsed); - assertType('mixed', $this->nonexistentProperty); + assertType('*ERROR*', $this->nonexistentProperty); assertType('int|null', $this->nullableInteger); assertType('SomeNamespace\Amet|null', $this->nullableObject); assertType('PropertiesNamespace\Foo', $this->selfType); diff --git a/tests/PHPStan/Analyser/nsrt/type-aliases.php b/tests/PHPStan/Analyser/nsrt/type-aliases.php index 978d20a9fbc..dd7440470f2 100644 --- a/tests/PHPStan/Analyser/nsrt/type-aliases.php +++ b/tests/PHPStan/Analyser/nsrt/type-aliases.php @@ -36,8 +36,8 @@ class Baz public function circularAlias() { - assertType('mixed', $this->baz); - assertType('mixed', $this->qux); + assertType('*ERROR*', $this->baz); + assertType('*ERROR*', $this->qux); } } diff --git a/tests/PHPStan/Analyser/nsrt/union-intersection.php b/tests/PHPStan/Analyser/nsrt/union-intersection.php index a531111f849..6db3b83bd3a 100644 --- a/tests/PHPStan/Analyser/nsrt/union-intersection.php +++ b/tests/PHPStan/Analyser/nsrt/union-intersection.php @@ -113,7 +113,7 @@ public function doFoo(WithFoo $foo, WithFoo $foobar, object $object) assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->foo); assertType('UnionIntersection\Bar', $this->union->bar); assertType('UnionIntersection\Foo', $foo->foo); - assertType('mixed', $foo->bar); + assertType('*ERROR*', $foo->bar); assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->doFoo()); assertType('UnionIntersection\Bar', $this->union->doBar()); assertType('UnionIntersection\Foo', $foo->doFoo()); diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 604be35a564..0d476571c8a 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1105,11 +1105,6 @@ public function testBug11609(): void 10, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], - [ - 'Strict comparison using !== between string and null will always evaluate to true.', - 13, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', - ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php index 338a83aaf21..e23fa3eb55f 100644 --- a/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php @@ -92,6 +92,10 @@ public function testRule(): void 'Class MethodAssert\Foo referenced with incorrect case: MethodAssert\fOO.', 105, ], + [ + 'Assert references unknown $this->barProp.', + 105, + ], [ 'Assert references unknown parameter $this.', 113, From 282bd8e6d0a082d3c6369924490823c431b11a75 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 13:08:20 +0200 Subject: [PATCH 331/378] Bind static to the named class when calling a static method through an explicit class name --- .../ExprHandler/StaticCallHandler.php | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 4efa10e2781..47ec5f7a1a1 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -46,12 +46,15 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeWithClassName; use ReflectionProperty; use function array_map; use function array_merge; use function count; +use function in_array; use function sprintf; use function strtolower; @@ -121,7 +124,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $closureBindScopeFactory = null; if ($expr->name instanceof Identifier) { if ($expr->class instanceof Name) { - $classType = $scope->resolveTypeByName($expr->class); + // the acceptor selected here feeds the call's return type - a + // STATIC method called through an explicit class name binds + // `static` to that class, so select from the demoted type + $classType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name->name); $methodName = $expr->name->name; if ($classType->hasMethod($methodName)->yes()) { $methodReflection = $classType->getMethod($methodName, $scope); @@ -443,7 +449,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin $resolveStaticMethod = function (string $methodName, StaticCall $staticCall) use ($reflectionScope, $nativeTypesPromoted, $classType, $nodeScopeResolver, $expr, $preResolvedAcceptor, $argsResult): Type { if ($nativeTypesPromoted) { if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($reflectionScope, $expr->class, $methodName); } else { $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope->doNotTreatPhpDocTypesAsCertain()); } @@ -456,7 +462,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin } if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($reflectionScope, $expr->class, $methodName); } else { $resolvedClassType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope); $staticMethodCalledOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); @@ -622,4 +628,30 @@ private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?Expre return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); } + /** + * An explicit class name within the current hierarchy resolves to a + * StaticType, but calling a STATIC method through it binds `static` to the + * named class - demote to the plain object type so `A::retStatic()` is `A`, + * not `static(self)`. self/static/parent keep late static binding. + */ + private function resolveTypeByNameWithLateStaticBinding(MutatingScope $scope, Name $class, string $methodName): TypeWithClassName + { + $classType = $scope->resolveTypeByName($class); + + if ( + $classType instanceof StaticType + && !in_array($class->toLowerString(), ['self', 'static', 'parent'], true) + ) { + $methodReflectionCandidate = $scope->getMethodReflection( + $classType, + $methodName, + ); + if ($methodReflectionCandidate !== null && $methodReflectionCandidate->isStatic()) { + $classType = $classType->getStaticObjectType(); + } + } + + return $classType; + } + } From a9ee7db931669e371122410db86b178daf080eda Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 13:14:56 +0200 Subject: [PATCH 332/378] Strip the nullsafe short-circuit null from a static call's class-expression before the reflection lookup --- src/Analyser/ExprHandler/StaticCallHandler.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 47ec5f7a1a1..6dbd9a98013 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -188,7 +188,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($expr->class instanceof Expr) { // the class expr was processed above as the receiver; read its // already-computed result instead of re-walking via Scope::getType(). - $classType = $classResult->getType()->getObjectTypeOrClassStringObjectType(); + // A nullsafe receiver's null is the chain short-circuit, not a + // callee - strip it before the reflection lookup, like the + // return-type resolution does. + $classType = TypeCombinator::removeNull($classResult->getType())->getObjectTypeOrClassStringObjectType(); $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { From ff15cfba76562bbe36435011cf5231d0930681d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 13:25:43 +0200 Subject: [PATCH 333/378] Fan receiver-not-null through nullsafe chains from composed create callbacks --- .../Helper/DefaultNarrowingHelper.php | 55 ++++++++++++++++++- .../ExprHandler/MethodCallHandler.php | 17 +++++- .../ExprHandler/NullsafeMethodCallHandler.php | 6 +- .../NullsafePropertyFetchHandler.php | 6 +- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 335ec317486..403c5d9ccc5 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -167,6 +167,17 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR } } + return $this->createSubjectTypesFromResultState($s, $subject, $subjectResult, $type, $context); + } + + /** + * The fallback entry building - createSubjectTypes() without consulting the + * result's createTypesCallback. A handler's OWN createTypesCallback delegates + * here with its stored result so the impure gate and the nullsafe-chain fan + * still read the result state without re-entering itself. + */ + public function createSubjectTypesFromResultState(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { if ($subject instanceof Expr\Instanceof_ || $subject instanceof Expr\List_) { return new SpecifiedTypes([], []); } @@ -209,6 +220,11 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR if ($nullRuledOut) { $exprToSpecify = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($subject); + // a plain fetch/call wrapped AROUND a nullsafe chain has no + // createTypesCallback of its own - fan "the chain did not + // short-circuit" through the first nullsafe below it, like + // the old create()'s createNullsafeTypes() union + $nullsafeFanTypes = $this->createFirstNullsafeReceiverTypes($s, $subject); } } } @@ -227,7 +243,44 @@ public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionR } } - return new SpecifiedTypes($sureTypes, $sureNotTypes); + $result = new SpecifiedTypes($sureTypes, $sureNotTypes); + if (isset($nullsafeFanTypes)) { + $result = $result->unionWith($nullsafeFanTypes); + } + + return $result; + } + + /** + * Narrows the first nullsafe link below a plain fetch/call wrapper to + * "not null" (composing through its stored result, so its own receiver + * chain fans too) - the wrapper spine itself contributes nothing. + */ + private function createFirstNullsafeReceiverTypes(MutatingScope $s, Expr $expr): ?SpecifiedTypes + { + while (true) { + if ($expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\NullsafeMethodCall) { + return $this->createSubjectTypes( + $s, + $expr, + $s->getCurrentExpressionResultStorage()?->findExpressionResult($expr), + new NullType(), + TypeSpecifierContext::createFalse(), + ); + } + + if ($expr instanceof PropertyFetch || $expr instanceof MethodCall || $expr instanceof Expr\ArrayDimFetch) { + $expr = $expr->var; + continue; + } + + if (($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) && $expr->class instanceof Expr) { + $expr = $expr->class; + continue; + } + + return null; + } } /** diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 6eb9a82a300..979a3024684 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -208,10 +208,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // MethodCall purity gate + tail entry. An impure call narrows to nothing. $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($expr, $varResult, $beforeScope): SpecifiedTypes { $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (!$this->isMethodCallNarrowable($s, $expr, $varResult)) { + return new SpecifiedTypes([], []); + } - return $this->isMethodCallNarrowable($s, $expr, $varResult) - ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) - : new SpecifiedTypes([], []); + // delegate with this call's own stored result (looked up at ask time, + // never captured) so a nullsafe receiver chain fans "not null" through + // the containsNullsafe state - the FromResultState variant skips the + // createTypesCallback consult that would re-enter this closure + return $this->defaultNarrowingHelper->createSubjectTypesFromResultState( + $s, + $expr, + $s->getCurrentExpressionResultStorage()?->findExpressionResult($expr), + $type, + $createContext, + ); }; // Store a preliminary result carrying the type/specify callbacks before the diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index ea3082c18fb..e251fc6bfab 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -179,7 +179,7 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: // a receiver that is itself a ?-> composes through the parent handler. - createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $receiverResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { // null() context: createForExpr never computes $containsNull and // emits no entry for the subject - behave the same. if ($context->null()) { @@ -204,9 +204,11 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // !containsNull: the plain inner methodCall narrowed by $type // (createNullsafeTypes), the original ?-> key (createForExpr's // double-key), and "receiver is not null". + // the receiver composes through its own result so a nullsafe + // receiver fans "not null" down its whole chain return $this->defaultNarrowingHelper->createSubjectTypes($s, $methodCall, $exprResult, $type, $context) ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) - ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse())) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, $receiverResult, new NullType(), TypeSpecifierContext::createFalse())) ->setRootExpr($expr); }, ); diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 1af05ff915a..8806ac00e92 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -159,7 +159,7 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. // The short-circuit's null surfaces here, never by walking the chain: // a receiver that is itself a ?-> composes through the parent handler. - createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { // null() context: createForExpr never computes $containsNull and // emits no entry for the subject - behave the same. if ($context->null()) { @@ -184,9 +184,11 @@ static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingSc // !containsNull: the plain inner propertyFetch narrowed by $type // (createNullsafeTypes), the original ?-> key (createForExpr's // double-key), and "receiver is not null". + // the receiver composes through its own result so a nullsafe + // receiver fans "not null" down its whole chain return $this->defaultNarrowingHelper->createSubjectTypes($s, $propertyFetch, $exprResult, $type, $context) ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) - ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, new NullType(), TypeSpecifierContext::createFalse())) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, $receiverResult, new NullType(), TypeSpecifierContext::createFalse())) ->setRootExpr($expr); }, ); From 488354f3b41d705b3bb0790a87291159c6270b5c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 13:46:53 +0200 Subject: [PATCH 334/378] Re-price stored results asked at a diverging variable position A stored ExpressionResult only answers an on-demand ask when the asking scope agrees with its evaluation position on the variables the expression reads; a counterfactual walk (an extension re-binding a variable, e.g. array_filter evaluating its callback per constant element) re-processes the node on its own scope. A trivially-matching stored answer consumed at a foreign position is re-anchored to the asking scope so it does not thread the original walk's scopes onward. --- src/Analyser/ExpressionResult.php | 90 +++++++++++++++++++++++ src/Analyser/MutatingScope.php | 12 ++- src/Analyser/NodeScopeResolver.php | 14 +++- tests/PHPStan/Analyser/nsrt/array-map.php | 6 +- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index dbfdb793a68..4d66ecce9dc 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PhpParser\NodeFinder; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\ShouldNotHappenException; @@ -11,6 +12,7 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; +use function array_keys; use function is_string; use function spl_object_id; @@ -46,6 +48,9 @@ final class ExpressionResult private ?Type $projectedNativeType = null; + /** @var list|null */ + private ?array $readVariableNames = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints @@ -463,4 +468,89 @@ private function isScopeAuthoritative(MutatingScope $scope): bool && $scope->hasExpressionType($this->expr)->yes(); } + /** + * Whether the asking scope agrees with this result's evaluation position on + * every variable the expression reads. A counterfactual ask - an extension + * re-binding a variable (e.g. array_filter evaluating its callback body per + * constant element) and pricing a real node - must not be answered from the + * memoized walk-position type; the caller re-prices the node on the asking + * scope instead. + */ + public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNativeTypes): bool + { + // same unpromoted position implies same promoted position - skip the + // flavour derivation for the common same-position ask + if ($scope === $this->beforeScope) { + return true; + } + $names = $this->getReadVariableNames(); + if ($names === []) { + return true; + } + + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + $positionScope = $useNativeTypes ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope; + if ($readScope === $positionScope) { + return true; + } + + foreach ($names as $name) { + $askKnows = $readScope->hasVariableType($name); + $positionKnows = $positionScope->hasVariableType($name); + if ($askKnows->no() && $positionKnows->no()) { + continue; + } + if (!$askKnows->equals($positionKnows)) { + return false; + } + if (!$readScope->getVariableType($name)->equals($positionScope->getVariableType($name))) { + return false; + } + } + + return true; + } + + /** + * A copy of this result answering at a foreign ask position: the scopes are + * re-anchored to the asking scope so an on-demand walk consuming this + * answer threads ITS position onward, not the original walk's. The + * position-dependent branch-scope memos and overrides are dropped - they + * belong to the original position and derive from the ask scope on demand. + */ + public function atAskPosition(MutatingScope $scope): self + { + $clone = clone $this; + $clone->scope = $scope; + $clone->beforeScope = $scope; + $clone->truthyScope = null; + $clone->falseyScope = null; + $clone->truthyScopeOverride = null; + $clone->falseyScopeOverride = null; + $clone->cachedType = null; + $clone->cachedNativeType = null; + + return $clone; + } + + /** + * @return list + */ + private function getReadVariableNames(): array + { + if ($this->readVariableNames !== null) { + return $this->readVariableNames; + } + + $names = []; + foreach ((new NodeFinder())->findInstanceOf([$this->expr], Expr\Variable::class) as $variable) { + if (!is_string($variable->name) || $variable->name === 'this') { + continue; + } + $names[$variable->name] = true; + } + + return $this->readVariableNames = array_keys($names); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index caf54cf2032..320db4b32cb 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1174,10 +1174,18 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type // a fiber $scope = $this->toMutatingScope(); $storage = $this->expressionResultStorageStack->getCurrent(); + $counterfactualAsk = false; if ($storage !== null) { $result = $storage->findExpressionResult($node); if ($result !== null && $result->canResolveOwnType()) { - return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + // a counterfactual ask (the asking scope re-binds a variable the + // expression reads, e.g. array_filter pricing its callback body + // per constant element) must re-price the node on that scope - + // the memoized walk-position type answers a different question + $counterfactualAsk = !$result->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted); + if (!$counterfactualAsk) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } } } @@ -1193,7 +1201,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $node); } - if ($storage !== null && $storage->findExpressionResult($node) !== null) { + if (!$counterfactualAsk && $storage !== null && $storage->findExpressionResult($node) !== null) { throw new ShouldNotHappenException(sprintf( 'ExpressionResult of %s cannot resolve its own type (no eager type, no typeCallback).', get_class($node), diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 25e091dc4f4..918ddd205be 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3057,8 +3057,18 @@ public function processExprNode( { if ($this->returnStoredExpressionResults) { $storedResult = $storage->findExpressionResult($expr); - if ($storedResult !== null) { - return $storedResult; + // a stored result only answers when the current scope agrees with its + // evaluation position on the variables the expression reads - a + // counterfactual walk (an extension re-binding a variable and pricing + // a real subtree, e.g. array_filter's per-element callback evaluation) + // re-processes the node on its own scope instead + if ($storedResult !== null && $storedResult->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted)) { + // a foreign-position answer must not thread its original walk + // scopes into THIS walk - re-anchor it to the asking position so + // subsequent operands keep evaluating on the asking scope + return $storedResult->getBeforeScope() === $scope + ? $storedResult + : $storedResult->atAskPosition($scope); } } diff --git a/tests/PHPStan/Analyser/nsrt/array-map.php b/tests/PHPStan/Analyser/nsrt/array-map.php index fe86a874bf6..5acfd1ab7a4 100644 --- a/tests/PHPStan/Analyser/nsrt/array-map.php +++ b/tests/PHPStan/Analyser/nsrt/array-map.php @@ -94,10 +94,8 @@ public function doFoo(): void assertType("array{'0', '1'}", array_map('strval', $a)); assertType("array{'0', '1'}", array_map(strval(...), $a)); - // per-constant-array-item closure reanalysis is not done, so the closure - // body is resolved once over the unioned element type rather than per item. - assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a)); - assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a)); + assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a)); + assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a)); } public function doFizzBuzz(): void From 872e9b00b565390ef2af6b3e0cf74ba0f8d79417 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 13:48:01 +0200 Subject: [PATCH 335/378] Restore per-item array_map expectations lifted by counterfactual re-pricing --- tests/PHPStan/Analyser/nsrt/bug-14649.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14649.php b/tests/PHPStan/Analyser/nsrt/bug-14649.php index de5a272c2af..f1e31730a83 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14649.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14649.php @@ -19,13 +19,11 @@ public function isGreaterThanOrEqual(Role $role): bool self::cases() ); - // per-constant-array-item closure reanalysis is not done, so the property - // fetch is resolved once over the unioned element type rather than per item. - assertType("array{'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER'}", $map); + assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map); $hierarchy = array_flip($map); - assertType("non-empty-array{ADMIN?: 0|1|2, EDITOR?: 0|1|2, OWNER?: 0|1|2}", $hierarchy); + assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy); return $hierarchy[$this->value] <= $hierarchy[$role->value]; } @@ -71,16 +69,12 @@ function testIntBackedEnum(): void static fn (IntEnum $e): int => $e->value, IntEnum::cases() ); - // per-constant-array-item closure reanalysis is not done, so the property - // fetch is resolved once over the unioned element type rather than per item. - assertType("array{10|20, 10|20}", $result); + assertType("array{10, 20}", $result); } function testClosureWithStringKeys(): void { $arr = ['x' => 1, 'y' => 2]; $result = array_map(fn(int $v): string => (string)$v, $arr); - // per-constant-array-item closure reanalysis is not done, so the cast is - // resolved once over the unioned element type rather than per item. - assertType("array{x: '1'|'2', y: '1'|'2'}", $result); + assertType("array{x: '1', y: '2'}", $result); } From 83c11ad87444d55eba7e0638e9388a965099e616 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 14:48:09 +0200 Subject: [PATCH 336/378] Process the nullsafe receiver once and let the plain twin consume its stored result The receiver is walked on the pre-ensure scope (real callbacks); the plain-twin walk runs in a consume-stored mode that answers already-walked subtrees from storage, re-anchored to the ensured position. The re-anchored view pins scope-authoritative types from the ask position's state and is pre-stored so rules suspended at the twin's callback see the deviced receiver. Scope-authoritative reads in getTypeOnScope now resolve late-resolvable types and project void to null like value reads do. --- .../ExprHandler/NullsafeMethodCallHandler.php | 30 ++++++---- .../NullsafePropertyFetchHandler.php | 30 ++++++---- src/Analyser/ExpressionResult.php | 17 +++++- src/Analyser/NodeScopeResolver.php | 57 ++++++++++++++++--- 4 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index e251fc6bfab..e69d8f4a050 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -56,19 +56,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $beforeScope = $scope; $scopeBeforeNullsafe = $scope; - // the receiver's real (possibly null) type, captured before it is ensured - // non-null below: the short-circuit decision needs to know it can be null, - // which reading the ensured-non-null result would hide. - // an enclosing isset/empty/?? ensure may have deviced the receiver - // non-null in scope state so nested fetches walk quietly; the - // short-circuit decision needs the receiver's REAL type from before - // that device - $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); - $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); + // the receiver is processed ONCE here, on the pre-ensure scope; the + // plain-twin walk below CONSUMES its stored result instead of re-walking + // it. Its result carries the receiver's real (possibly null) type - the + // short-circuit decision needs to know it can be null, which reading the + // ensured-non-null state would hide. An enclosing isset/empty/?? ensure + // may have deviced the receiver in scope state, so the ensure stack's + // original type still wins. + $processedReceiverResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $processedReceiverResult->getScope(); + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $processedReceiverResult->getType(); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $processedReceiverResult->getNativeType(); // carry the receiver type to NullsafeMethodCallRule so it reads it from here // instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + // pre-store the receiver's ensured-position view: rules suspended at the + // plain twin's callback ask about the receiver BEFORE the twin walk + // consumes it, and must see the same (deviced non-null) answer the twin + // walk itself will consume - exactly what storing the receiver walked + // inside the twin used to produce + $nodeScopeResolver->storeExpressionResult($storage, $expr->var, $processedReceiverResult->atAskPosition($nonNullabilityResult->getScope())); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $methodCall = new MethodCall( @@ -77,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->args, $attributes, ); - $exprResult = $nodeScopeResolver->processExprNode( + $exprResult = $nodeScopeResolver->processExprNodeConsumingStored( $stmt, $methodCall, $nonNullabilityResult->getScope(), @@ -120,7 +128,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's stored result, for composing the receiver-not-null // narrowing without re-walking the chain - $receiverResult = $storage->findExpressionResult($expr->var); + $receiverResult = $processedReceiverResult; // lazily memoized receiver-is-null branch scope of the decomposition $leftFalseyScope = null; diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 8806ac00e92..4bc4f45ccc6 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -54,19 +54,27 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - // the receiver's real (possibly null) type, captured before it is ensured - // non-null below: the short-circuit decision needs to know it can be null, - // which reading the ensured-non-null result would hide. - // an enclosing isset/empty/?? ensure may have deviced the receiver - // non-null in scope state so nested fetches walk quietly; the - // short-circuit decision needs the receiver's REAL type from before - // that device - $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); - $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); + // the receiver is processed ONCE here, on the pre-ensure scope; the + // plain-twin walk below CONSUMES its stored result instead of re-walking + // it. Its result carries the receiver's real (possibly null) type - the + // short-circuit decision needs to know it can be null, which reading the + // ensured-non-null state would hide. An enclosing isset/empty/?? ensure + // may have deviced the receiver in scope state, so the ensure stack's + // original type still wins. + $processedReceiverResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $processedReceiverResult->getScope(); + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $processedReceiverResult->getType(); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $processedReceiverResult->getNativeType(); // carry the receiver type to NullsafePropertyFetchRule so it reads it from // here instead of asking the scope for the unprocessed receiver. $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + // pre-store the receiver's ensured-position view: rules suspended at the + // plain twin's callback ask about the receiver BEFORE the twin walk + // consumes it, and must see the same (deviced non-null) answer the twin + // walk itself will consume - exactly what storing the receiver walked + // inside the twin used to produce + $nodeScopeResolver->storeExpressionResult($storage, $expr->var, $processedReceiverResult->atAskPosition($nonNullabilityResult->getScope())); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $propertyFetch = new PropertyFetch( @@ -74,7 +82,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->name, $attributes, ); - $exprResult = $nodeScopeResolver->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); + $exprResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); // The `?->`'s own type on the asking scope. $receiverType is the receiver's @@ -100,7 +108,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // the receiver's stored result, for composing the receiver-not-null // narrowing without re-walking the chain - $receiverResult = $storage->findExpressionResult($expr->var); + $receiverResult = $processedReceiverResult; // lazily memoized receiver-is-null branch scope of the decomposition $leftFalseyScope = null; diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 4d66ecce9dc..7152de7c70a 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -442,7 +442,9 @@ public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type { $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; if ($this->type === null && $this->isScopeAuthoritative($readScope)) { - return $readScope->getStateType($this->expr); + // the state read is a value read: resolve late-resolvable types and + // project void to null exactly like resolveOwnType() does + return $this->projectVoidToNull(TypeUtils::resolveLateResolvableTypes($readScope->getStateType($this->expr))); } return $this->resolveOwnType($useNativeTypes); @@ -529,6 +531,19 @@ public function atAskPosition(MutatingScope $scope): self $clone->falseyScopeOverride = null; $clone->cachedType = null; $clone->cachedNativeType = null; + // a scope-authoritative expression's type is pinned eagerly from the ask + // position's state - the original callbacks capture the original + // position's scopes and would answer stale types (e.g. a variable + // receiver consumed on an ensured-non-null scope) + if ($this->type === null && $this->isScopeAuthoritative($scope)) { + $clone->type = $scope->getStateType($this->expr); + $clone->nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($this->expr); + $clone->typeCallback = null; + $clone->resolvedType = null; + $clone->resolvedNativeType = null; + $clone->projectedType = null; + $clone->projectedNativeType = null; + } return $clone; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 918ddd205be..24d1b0a4085 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -216,6 +216,14 @@ class NodeScopeResolver */ protected bool $returnStoredExpressionResults = false; + /** + * Consume-stored mode: a walk that deliberately re-enters an + * already-walked subtree (the nullsafe plain twin re-walking its + * receiver) consumes stored results unconditionally instead of + * re-processing - node callbacks fired during the original walk. + */ + private bool $consumeStoredExpressionResults = false; + /** * spl_object_id => recursion depth of the expressions currently being * processed by processExprNode. A fiber pending on one of them must not be @@ -229,7 +237,7 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; - /** + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can * tell a real AST node from a node a rule built during analysis (which @@ -2919,6 +2927,26 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo * their already-stored results instead of being processed again. New results * are stored into the given storage - pass a duplicate to keep them isolated. */ + /** + * Processes an expression whose already-walked subtrees must be CONSUMED + * from their stored results instead of re-walked: the nullsafe handlers + * process the receiver once (real callbacks) and then walk the plain twin, + * whose receiver subtree answers from storage, re-anchored to the twin's + * (ensured) scope. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processExprNodeConsumingStored(Node\Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $previous = $this->consumeStoredExpressionResults; + $this->consumeStoredExpressionResults = true; + try { + return $this->processExprNode($stmt, $expr, $scope, $storage, $nodeCallback, $context); + } finally { + $this->consumeStoredExpressionResults = $previous; + } + } + public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult { $this->returnStoredExpressionResults = true; @@ -3055,20 +3083,34 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { - if ($this->returnStoredExpressionResults) { + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { $storedResult = $storage->findExpressionResult($expr); // a stored result only answers when the current scope agrees with its // evaluation position on the variables the expression reads - a // counterfactual walk (an extension re-binding a variable and pricing // a real subtree, e.g. array_filter's per-element callback evaluation) - // re-processes the node on its own scope instead - if ($storedResult !== null && $storedResult->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted)) { + // re-processes the node on its own scope instead. In CONSUME mode the + // divergence is intentional (an ensured-non-null device) and the + // stored result is consumed unconditionally, re-anchored below. + if ($storedResult !== null && ($this->consumeStoredExpressionResults || $storedResult->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted))) { // a foreign-position answer must not thread its original walk // scopes into THIS walk - re-anchor it to the asking position so // subsequent operands keep evaluating on the asking scope - return $storedResult->getBeforeScope() === $scope - ? $storedResult - : $storedResult->atAskPosition($scope); + if ($storedResult->getBeforeScope() === $scope) { + return $storedResult; + } + + $reanchored = $storedResult->atAskPosition($scope); + if ($this->consumeStoredExpressionResults) { + // the re-anchored view IS this walk's result for the node + // (the nullsafe twin's receiver at the ensured position) - + // store it so later asks (rules' fiber reads) see the same + // result the twin walk itself consumed, exactly like the + // receiver walked inside the twin used to be stored + $this->storeExpressionResult($storage, $expr, $reanchored); + } + + return $reanchored; } } @@ -3101,6 +3143,7 @@ private function processExprNodeInternal( ExpressionContext $context, ): ExpressionResult { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); From dbf535b8140361b890e404aab494f8df49c99178 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 14:56:42 +0200 Subject: [PATCH 337/378] Exempt closures and arrow functions from the PHPSTAN_GUARD_NW pre-processing guard --- src/Analyser/MutatingScope.php | 6 ++++++ src/Analyser/NodeScopeResolver.php | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 320db4b32cb..fcaf1c3b450 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -986,6 +986,10 @@ public function getType(Expr $node): Type { if ( NodeScopeResolver::$guardNewWorld + // closures/arrow functions are priced compute-direct (getClosureType) + // by design - asking about one before its walk is not a violation + && !$node instanceof Expr\Closure + && !$node instanceof Expr\ArrowFunction && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { @@ -1329,6 +1333,8 @@ public function obtainResultForNode(Expr $node): ExpressionResult if ( NodeScopeResolver::$guardNewWorld + && !$node instanceof Expr\Closure + && !$node instanceof Expr\ArrowFunction && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 24d1b0a4085..3a408cc9920 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -237,7 +237,7 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; - /** + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can * tell a real AST node from a node a rule built during analysis (which @@ -3042,6 +3042,10 @@ private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): vo { if ( !self::$guardNewWorld + // closures/arrow functions are priced compute-direct (getClosureType) + // by design - asking about one before its walk is not a violation + || $expr instanceof Expr\Closure + || $expr instanceof Expr\ArrowFunction || !isset(self::$guardRealExprIds[spl_object_id($expr)]) || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) ) { @@ -3144,6 +3148,7 @@ private function processExprNodeInternal( ): ExpressionResult { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); From 79ccb9fa4e8f01115acedd1a44508840d0d7de0b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 15:12:28 +0200 Subject: [PATCH 338/378] Exempt constant shapes and variable reads from the PHPSTAN_GUARD_NW guard --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 2 +- src/Analyser/MutatingScope.php | 8 ++----- src/Analyser/NodeScopeResolver.php | 24 +++++++++++++++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 6e7d2cd8df8..d497727a221 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -133,7 +133,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void // means a rule asked about its type but it was never processed and // stored during natural traversal - a gap to fix at the producing // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. - if (self::$guardNewWorld && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { + if (self::$guardNewWorld && !self::isGuardExemptNode($request->expr) && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { throw new ShouldNotHappenException(sprintf( 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', get_class($request->expr), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fcaf1c3b450..3307293015c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -986,10 +986,7 @@ public function getType(Expr $node): Type { if ( NodeScopeResolver::$guardNewWorld - // closures/arrow functions are priced compute-direct (getClosureType) - // by design - asking about one before its walk is not a violation - && !$node instanceof Expr\Closure - && !$node instanceof Expr\ArrowFunction + && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { @@ -1333,8 +1330,7 @@ public function obtainResultForNode(Expr $node): ExpressionResult if ( NodeScopeResolver::$guardNewWorld - && !$node instanceof Expr\Closure - && !$node instanceof Expr\ArrowFunction + && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3a408cc9920..b3db2c35940 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -237,6 +237,25 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; + /** + * Nodes the PHPSTAN_GUARD_NW diagnostic never fires for: closures/arrow + * functions are priced compute-direct (getClosureType) by design, and + * constant shapes (scalars, constant fetches, class constants on a named + * class) are position-independent - pricing them before their walk cannot + * read stale state. + */ + public static function isGuardExemptNode(Expr $expr): bool + { + return $expr instanceof Expr\Closure + || $expr instanceof Expr\ArrowFunction + || $expr instanceof Node\Scalar + || $expr instanceof Expr\ConstFetch + || ($expr instanceof Expr\ClassConstFetch && $expr->class instanceof Name) + // a variable read answers from scope state (getVariableType) - it is + // correct at any position, before or after the node's own walk + || ($expr instanceof Expr\Variable && is_string($expr->name)); + } + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can @@ -3042,10 +3061,7 @@ private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): vo { if ( !self::$guardNewWorld - // closures/arrow functions are priced compute-direct (getClosureType) - // by design - asking about one before its walk is not a violation - || $expr instanceof Expr\Closure - || $expr instanceof Expr\ArrowFunction + || self::isGuardExemptNode($expr) || !isset(self::$guardRealExprIds[spl_object_id($expr)]) || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) ) { From 247d2240c4f801787d1d71d52d535f5feacef8df Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 15:31:05 +0200 Subject: [PATCH 339/378] Sanction deliberate before-the-walk reads under the PHPSTAN_GUARD_NW guard An isset/?? ensure prices its subject before the walk the device enables, a statement-level @var reads the annotated expression's pre-annotation type, and lazy constructor property inference prices initializers outside any walk - all by design. sanctionedGuardRead() scopes an explicit guard bypass around those reads so the diagnostic keeps catching unintended pre-processing asks. --- .../Helper/NonNullabilityHelper.php | 17 ++++--- src/Analyser/MutatingScope.php | 2 + src/Analyser/NodeScopeResolver.php | 44 ++++++++++++++++++- .../Php/PhpClassReflectionExtension.php | 5 ++- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 19da0087576..3deceda3d1f 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; @@ -83,8 +84,10 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco { // the expression has not been processed into the storage yet (this runs // before processExprNode) - derive its current type from the scope's - // tracked state instead of pricing the node on demand. - $exprType = $scope->getStateType($exprToSpecify); + // tracked state. A non-narrowable subject (a call, a fresh fetch) has no + // tracked state and must be priced before its walk BY DESIGN: the device + // this ensure writes is what the walk runs on - a sanctioned read. + $exprType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getStateType($exprToSpecify)); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -94,9 +97,9 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getStateType($exprToSpecify); + $originalExprType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $originalScope->getStateType($exprToSpecify)); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); + $originalNativeType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify)); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -114,8 +117,8 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getStateType($parentExpr), - $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr), + NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getStateType($parentExpr)), + NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr)), $originalScope->hasExpressionType($parentExpr), ); } @@ -126,7 +129,7 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $certainty = $hasExpressionType; } - $nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); + $nativeType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify)); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3307293015c..a826960d4a0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -986,6 +986,7 @@ public function getType(Expr $node): Type { if ( NodeScopeResolver::$guardNewWorld + && !NodeScopeResolver::isInSanctionedGuardRead() && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) @@ -1330,6 +1331,7 @@ public function obtainResultForNode(Expr $node): ExpressionResult if ( NodeScopeResolver::$guardNewWorld + && !NodeScopeResolver::isInSanctionedGuardRead() && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index b3db2c35940..53bf22f31e1 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -237,6 +237,42 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; + /** + * Depth of sanctioned before-the-walk reads in progress. Some reads price + * a node the walk will process later BY DESIGN - an isset/?? ensure needs + * the subject's pre-device type before it can device the scope the walk + * runs on, a statement-level @var needs the annotated expression's + * original type to detect the annotation changing it, and lazy property + * inference prices initializers outside any walk. Those sites wrap the + * read in sanctionedGuardRead() so the PHPSTAN_GUARD_NW diagnostic keeps + * catching the UNINTENDED pre-processing reads. + */ + private static int $guardSanctionDepth = 0; + + /** + * @template T + * @param callable(): T $read + * @return T + */ + public static function sanctionedGuardRead(callable $read) + { + if (!self::$guardNewWorld) { + return $read(); + } + + self::$guardSanctionDepth++; + try { + return $read(); + } finally { + self::$guardSanctionDepth--; + } + } + + public static function isInSanctionedGuardRead(): bool + { + return self::$guardSanctionDepth > 0; + } + /** * Nodes the PHPSTAN_GUARD_NW diagnostic never fires for: closures/arrow * functions are priced compute-direct (getClosureType) by design, and @@ -3061,6 +3097,7 @@ private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): vo { if ( !self::$guardNewWorld + || self::$guardSanctionDepth > 0 || self::isGuardExemptNode($expr) || !isset(self::$guardRealExprIds[spl_object_id($expr)]) || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) @@ -4976,7 +5013,12 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $this->readTypeOfMaybeStored($defaultExpr, $scope); + // the annotated expression is processed by its statement handler AFTER + // this annotation runs, and this read deliberately wants its type + // BEFORE the @var applies - a sanctioned before-the-walk read (a + // store-only pre-walk is unsound: rules suspended at the statement + // callback would resume on the pre-annotation result) + $originalType = self::sanctionedGuardRead(fn (): Type => $this->readTypeOfMaybeStored($defaultExpr, $scope)); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 16a725cf433..2d2192f0b34 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -1223,7 +1223,10 @@ private function inferAndCachePropertyTypes( continue; } - $propertyType = $methodScope->getType($expr->expr); + // lazy inference prices constructor-assignment values outside any + // walk, on a scope of its own - a sanctioned before-the-walk read + // (the parser cache shares these nodes with the main analysis) + $propertyType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $methodScope->getType($expr->expr)); if ($propertyType instanceof ErrorType || $propertyType instanceof NeverType) { continue; } From c98b28fa45bb1d689b55a2902d97a42a39e3e00b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 15:33:57 +0200 Subject: [PATCH 340/378] Sanction closure-signature reads of parameter defaults and sibling call arguments --- .../ExprHandler/Helper/ClosureTypeResolver.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 2b3c0176c24..5a8b1d1a66b 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -681,7 +681,10 @@ private function buildParametersAndAcceptors( ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), $param->variadic, - $param->default !== null ? $scope->getType($param->default) : null, + // a parameter default is a constant expression (PHP requires it) - + // pricing it during signature construction, possibly before the + // closure's walk, is position-independent and sanctioned + $param->default !== null ? NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getType($param->default)) : null, ); } @@ -693,8 +696,11 @@ private function buildParametersAndAcceptors( $callableParameters = []; $nativeCallableParameters = []; foreach ($arrayMapArgs as $funcCallArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + // the closure's parameter types derive from a SIBLING call argument + // that is processed after the closure - a deliberate + // before-the-walk read (the acceptor-selection floor) + $callableParameters[] = new DummyParameter('item', NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getType($funcCallArg->value))->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getNativeType($funcCallArg->value))->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } elseif ($immediatelyInvokedArgs !== null) { foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { From 5f0f1992c2780a61d2717b2c407e7026644e995d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 15:35:51 +0200 Subject: [PATCH 341/378] Revert the PHPSTAN_GUARD_NW exemptions and sanctioned reads Driving the per-expression reprocessing count down is the goal; the guard goes green as a consequence of eliminating the underlying re-walks, not by carving out allowances for them. --- .../Helper/ClosureTypeResolver.php | 12 +--- .../Helper/NonNullabilityHelper.php | 17 ++--- src/Analyser/Fiber/FiberNodeScopeResolver.php | 2 +- src/Analyser/MutatingScope.php | 4 -- src/Analyser/NodeScopeResolver.php | 67 +------------------ .../Php/PhpClassReflectionExtension.php | 5 +- 6 files changed, 14 insertions(+), 93 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 5a8b1d1a66b..2b3c0176c24 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -681,10 +681,7 @@ private function buildParametersAndAcceptors( ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), $param->variadic, - // a parameter default is a constant expression (PHP requires it) - - // pricing it during signature construction, possibly before the - // closure's walk, is position-independent and sanctioned - $param->default !== null ? NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getType($param->default)) : null, + $param->default !== null ? $scope->getType($param->default) : null, ); } @@ -696,11 +693,8 @@ private function buildParametersAndAcceptors( $callableParameters = []; $nativeCallableParameters = []; foreach ($arrayMapArgs as $funcCallArg) { - // the closure's parameter types derive from a SIBLING call argument - // that is processed after the closure - a deliberate - // before-the-walk read (the acceptor-selection floor) - $callableParameters[] = new DummyParameter('item', NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getType($funcCallArg->value))->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getNativeType($funcCallArg->value))->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } elseif ($immediatelyInvokedArgs !== null) { foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 3deceda3d1f..19da0087576 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,7 +11,6 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; @@ -84,10 +83,8 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco { // the expression has not been processed into the storage yet (this runs // before processExprNode) - derive its current type from the scope's - // tracked state. A non-narrowable subject (a call, a fresh fetch) has no - // tracked state and must be priced before its walk BY DESIGN: the device - // this ensure writes is what the walk runs on - a sanctioned read. - $exprType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getStateType($exprToSpecify)); + // tracked state instead of pricing the node on demand. + $exprType = $scope->getStateType($exprToSpecify); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -97,9 +94,9 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $originalScope->getStateType($exprToSpecify)); + $originalExprType = $originalScope->getStateType($exprToSpecify); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify)); + $originalNativeType = $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -117,8 +114,8 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->getStateType($parentExpr)), - NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr)), + $scope->getStateType($parentExpr), + $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr), $originalScope->hasExpressionType($parentExpr), ); } @@ -129,7 +126,7 @@ private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingSco $certainty = $hasExpressionType; } - $nativeType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify)); + $nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index d497727a221..6e7d2cd8df8 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -133,7 +133,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void // means a rule asked about its type but it was never processed and // stored during natural traversal - a gap to fix at the producing // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. - if (self::$guardNewWorld && !self::isGuardExemptNode($request->expr) && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { + if (self::$guardNewWorld && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { throw new ShouldNotHappenException(sprintf( 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', get_class($request->expr), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a826960d4a0..320db4b32cb 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -986,8 +986,6 @@ public function getType(Expr $node): Type { if ( NodeScopeResolver::$guardNewWorld - && !NodeScopeResolver::isInSanctionedGuardRead() - && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { @@ -1331,8 +1329,6 @@ public function obtainResultForNode(Expr $node): ExpressionResult if ( NodeScopeResolver::$guardNewWorld - && !NodeScopeResolver::isInSanctionedGuardRead() - && !NodeScopeResolver::isGuardExemptNode($node) && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) ) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 53bf22f31e1..24d1b0a4085 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -237,62 +237,7 @@ class NodeScopeResolver /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; - /** - * Depth of sanctioned before-the-walk reads in progress. Some reads price - * a node the walk will process later BY DESIGN - an isset/?? ensure needs - * the subject's pre-device type before it can device the scope the walk - * runs on, a statement-level @var needs the annotated expression's - * original type to detect the annotation changing it, and lazy property - * inference prices initializers outside any walk. Those sites wrap the - * read in sanctionedGuardRead() so the PHPSTAN_GUARD_NW diagnostic keeps - * catching the UNINTENDED pre-processing reads. - */ - private static int $guardSanctionDepth = 0; - - /** - * @template T - * @param callable(): T $read - * @return T - */ - public static function sanctionedGuardRead(callable $read) - { - if (!self::$guardNewWorld) { - return $read(); - } - - self::$guardSanctionDepth++; - try { - return $read(); - } finally { - self::$guardSanctionDepth--; - } - } - - public static function isInSanctionedGuardRead(): bool - { - return self::$guardSanctionDepth > 0; - } - - /** - * Nodes the PHPSTAN_GUARD_NW diagnostic never fires for: closures/arrow - * functions are priced compute-direct (getClosureType) by design, and - * constant shapes (scalars, constant fetches, class constants on a named - * class) are position-independent - pricing them before their walk cannot - * read stale state. - */ - public static function isGuardExemptNode(Expr $expr): bool - { - return $expr instanceof Expr\Closure - || $expr instanceof Expr\ArrowFunction - || $expr instanceof Node\Scalar - || $expr instanceof Expr\ConstFetch - || ($expr instanceof Expr\ClassConstFetch && $expr->class instanceof Name) - // a variable read answers from scope state (getVariableType) - it is - // correct at any position, before or after the node's own walk - || ($expr instanceof Expr\Variable && is_string($expr->name)); - } - - /** + /** * spl_object_id => true of every Expr in the file's parsed AST. Populated * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can * tell a real AST node from a node a rule built during analysis (which @@ -3097,8 +3042,6 @@ private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): vo { if ( !self::$guardNewWorld - || self::$guardSanctionDepth > 0 - || self::isGuardExemptNode($expr) || !isset(self::$guardRealExprIds[spl_object_id($expr)]) || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) ) { @@ -3201,7 +3144,6 @@ private function processExprNodeInternal( ): ExpressionResult { - if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); @@ -5013,12 +4955,7 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - // the annotated expression is processed by its statement handler AFTER - // this annotation runs, and this read deliberately wants its type - // BEFORE the @var applies - a sanctioned before-the-walk read (a - // store-only pre-walk is unsound: rules suspended at the statement - // callback would resume on the pre-annotation result) - $originalType = self::sanctionedGuardRead(fn (): Type => $this->readTypeOfMaybeStored($defaultExpr, $scope)); + $originalType = $this->readTypeOfMaybeStored($defaultExpr, $scope); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 2d2192f0b34..16a725cf433 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -1223,10 +1223,7 @@ private function inferAndCachePropertyTypes( continue; } - // lazy inference prices constructor-assignment values outside any - // walk, on a scope of its own - a sanctioned before-the-walk read - // (the parser cache shares these nodes with the main analysis) - $propertyType = NodeScopeResolver::sanctionedGuardRead(static fn (): Type => $methodScope->getType($expr->expr)); + $propertyType = $methodScope->getType($expr->expr); if ($propertyType instanceof ErrorType || $propertyType instanceof NeverType) { continue; } From b0d13377a8f2665464d77d6f360d0a271afb18db Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 15:48:11 +0200 Subject: [PATCH 342/378] Carry narrowing subjects' ExpressionResults into SpecifiedTypes applySpecifiedTypes() reads a subject's current type pair through the result captured where the narrowing was composed, instead of pricing the node on demand when the applying scope's storage no longer sees it. The scope-state read for narrowable shapes stays authoritative; the carried result only replaces the miss-path walk. SpecifiedTypes' copy methods are clone-based now so new carried state propagates by construction. --- .../Helper/DefaultNarrowingHelper.php | 6 ++ src/Analyser/MutatingScope.php | 12 ++- src/Analyser/SpecifiedTypes.php | 94 ++++++++----------- 3 files changed, 54 insertions(+), 58 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 403c5d9ccc5..c925e4af955 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -244,6 +244,12 @@ public function createSubjectTypesFromResultState(MutatingScope $s, Expr $subjec } $result = new SpecifiedTypes($sureTypes, $sureNotTypes); + if ($subjectResult !== null && ($sureTypes !== [] || $sureNotTypes !== [])) { + // carry the subject's result so applySpecifiedTypes() reads its + // current type pair through it instead of pricing the node on + // demand when the applying scope's storage no longer sees it + $result = $result->withSubjectResult($this->exprPrinter->printExpr($subject), $subjectResult); + } if (isset($nullsafeFanTypes)) { $result = $result->unionWith($nullsafeFanTypes); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 320db4b32cb..c1c4994cddf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1232,7 +1232,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type * * @return array{Type, Type}|null */ - private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array + private function getCurrentTypesOfSpecifiedExpr(Expr $expr, ?ExpressionResult $subjectResult = null): ?array { $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage === null) { @@ -1259,6 +1259,14 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array $result = $storage->findExpressionResult($expr); if ($result === null) { + // a subject result captured where the narrowing was composed answers + // like a storage hit would - no walk + if ($subjectResult !== null) { + return [ + $subjectResult->getTypeOnScope($this, $this->nativeTypesPromoted), + $subjectResult->getTypeOnScope($this, true), + ]; + } // a call subject (or a synthetic plain-chain variant) is priced on // demand once per scope: one walk answers both flavours, and the @@ -3641,7 +3649,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $trackedNativeType = $scope->nativeExpressionTypes[$exprString]->getType(); } if ($trackedType === null) { - $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr); + $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr, $specifiedTypes->getSubjectResult($exprString)); if ($currentTypes !== null) { if ($scope->isComplexUnionType($currentTypes[0])) { continue; diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 0fd9dd63a1d..7ed91b656f3 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -53,6 +53,16 @@ final class SpecifiedTypes */ private array $alternativeTypes = []; + /** + * The narrowing subjects' ExpressionResults, captured where the narrowing + * was composed - applySpecifiedTypes() reads a subject's current type pair + * through its result instead of pricing the node on demand when the + * applying scope's storage no longer sees it. + * + * @var array + */ + private array $subjectResults = []; + /** * @api * @param array $sureTypes @@ -96,13 +106,8 @@ public static function emptySpecifyCallback(): Closure */ public function setAlwaysOverwriteTypes(): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; + $self = clone $this; $self->overwrite = true; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; - $self->deferredAugments = $this->deferredAugments; - $self->rootExpr = $this->rootExpr; return $self; } @@ -112,12 +117,7 @@ public function setAlwaysOverwriteTypes(): self */ public function setRootExpr(?Expr $rootExpr): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; - $self->deferredAugments = $this->deferredAugments; + $self = clone $this; $self->rootExpr = $rootExpr; return $self; @@ -128,13 +128,8 @@ public function setRootExpr(?Expr $rootExpr): self */ public function setNewConditionalExpressionHolders(array $newConditionalExpressionHolders): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; + $self = clone $this; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; - $self->deferredAugments = $this->deferredAugments; - $self->rootExpr = $this->rootExpr; return $self; } @@ -144,13 +139,8 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi */ public function setConditionalExpressionHolderRecipes(array $recipes): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self = clone $this; $self->conditionalExpressionHolderRecipes = $recipes; - $self->deferredAugments = $this->deferredAugments; - $self->rootExpr = $this->rootExpr; return $self; } @@ -165,13 +155,8 @@ public function getConditionalExpressionHolderRecipes(): array public function withDeferredAugment(DeferredSpecifiedTypesAugment $augment): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self = clone $this; $self->deferredAugments = [...$this->deferredAugments, $augment]; - $self->rootExpr = $this->rootExpr; return $self; } @@ -181,13 +166,8 @@ public function withDeferredAugment(DeferredSpecifiedTypesAugment $augment): sel */ public function setDeferredAugments(array $augments): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; + $self = clone $this; $self->deferredAugments = $augments; - $self->rootExpr = $this->rootExpr; return $self; } @@ -226,6 +206,20 @@ public function getAlternativeTypes(): array return $this->alternativeTypes; } + /** The narrowing subject's ExpressionResult captured at compose time. */ + public function getSubjectResult(string $exprString): ?ExpressionResult + { + return $this->subjectResults[$exprString] ?? null; + } + + public function withSubjectResult(string $exprString, ExpressionResult $result): self + { + $self = clone $this; + $self->subjectResults[$exprString] = $result; + + return $self; + } + /** * A copy of this with the other's alternative-form entries - for the * composition tails that rebuild a SpecifiedTypes from the sure/sure-not @@ -233,13 +227,8 @@ public function getAlternativeTypes(): array */ public function withAlternativeTypesOf(self $other): self { - $self = new self($this->sureTypes, $this->sureNotTypes); + $self = clone $this; $self->alternativeTypes = $other->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; - $self->deferredAugments = $this->deferredAugments; - $self->rootExpr = $this->rootExpr; return $self; } @@ -264,20 +253,11 @@ public function getRootExpr(): ?Expr public function removeExpr(string $exprString): self { - $sureTypes = $this->sureTypes; - $sureNotTypes = $this->sureNotTypes; - $alternativeTypes = $this->alternativeTypes; - unset($sureTypes[$exprString]); - unset($sureNotTypes[$exprString]); - unset($alternativeTypes[$exprString]); - - $self = new self($sureTypes, $sureNotTypes); - $self->alternativeTypes = $alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->conditionalExpressionHolderRecipes = $this->conditionalExpressionHolderRecipes; - $self->deferredAugments = $this->deferredAugments; - $self->rootExpr = $this->rootExpr; + $self = clone $this; + unset($self->sureTypes[$exprString]); + unset($self->sureNotTypes[$exprString]); + unset($self->alternativeTypes[$exprString]); + unset($self->subjectResults[$exprString]); return $self; } @@ -353,6 +333,7 @@ public function intersectWith(SpecifiedTypes $other): self $result = new self($sureTypeUnion, $sureNotTypeUnion); $result->alternativeTypes = $alternativeUnion; + $result->subjectResults = $this->subjectResults + $other->subjectResults; if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -433,6 +414,7 @@ public function unionWith(SpecifiedTypes $other): self $result = new self($sureTypeUnion, $sureNotTypeUnion); $result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes; + $result->subjectResults = $this->subjectResults + $other->subjectResults; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } From 1a2699e9d4f311dbeb6362dddea92ae2bab4623b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 16:24:35 +0200 Subject: [PATCH 343/378] Attach a result to its own narrowing entries as a weak subject reference getSpecifiedTypes()/getCreatedTypes() attach the producing result to entries keyed by its own node, so call-shaped subjects carry themselves regardless of which compose path built the entry. Weakly referenced: the narrowing is memoized on the result itself, so a strong reference would be a refcount self-cycle gc_disable() never collects; a subject released with its body falls back to on-demand pricing. --- src/Analyser/ExpressionResult.php | 10 ++++++-- src/Analyser/SpecifiedTypes.php | 39 +++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7152de7c70a..6ed5e7804e5 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -399,7 +399,11 @@ public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTyp { $key = (spl_object_id($context) << 1) | ($nativeTypesPromoted ? 1 : 0); - return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted); + // entries keyed by this very node carry this result, so + // applySpecifiedTypes() reads the subject's current pair through it + // instead of pricing the node on demand + return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted) + ->withSubjectResultForExprNode($this->expr, $this); } /** @@ -428,7 +432,9 @@ public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool return null; } - return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted); + // see getSpecifiedTypes() - the subject carries itself + return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted) + ->withSubjectResultForExprNode($this->expr, $this); } /** diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 7ed91b656f3..b1cf855dbfe 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -4,6 +4,7 @@ use Closure; use PhpParser\Node\Expr; +use WeakReference; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -57,9 +58,13 @@ final class SpecifiedTypes * The narrowing subjects' ExpressionResults, captured where the narrowing * was composed - applySpecifiedTypes() reads a subject's current type pair * through its result instead of pricing the node on demand when the - * applying scope's storage no longer sees it. + * applying scope's storage no longer sees it. Weakly referenced: a result + * attaches ITSELF to its own narrowing (getSpecifiedTypes memoizes that + * narrowing on the result), so a strong reference would be a refcount + * self-cycle that gc_disable() never collects - and a subject released + * with its body simply falls back to the on-demand pricing. * - * @var array + * @var array> */ private array $subjectResults = []; @@ -209,17 +214,43 @@ public function getAlternativeTypes(): array /** The narrowing subject's ExpressionResult captured at compose time. */ public function getSubjectResult(string $exprString): ?ExpressionResult { - return $this->subjectResults[$exprString] ?? null; + if (!isset($this->subjectResults[$exprString])) { + return null; + } + + return $this->subjectResults[$exprString]->get(); } public function withSubjectResult(string $exprString, ExpressionResult $result): self { $self = clone $this; - $self->subjectResults[$exprString] = $result; + $self->subjectResults[$exprString] = WeakReference::create($result); return $self; } + /** + * Attaches the result to every entry whose subject IS the given node - how + * an ExpressionResult carries itself into the narrowing its own callbacks + * produced, without a printer. + */ + public function withSubjectResultForExprNode(Expr $expr, ExpressionResult $result): self + { + $self = null; + foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes] as $entries) { + foreach ($entries as $exprString => $entry) { + if ($entry[0] !== $expr || isset($this->subjectResults[$exprString])) { + continue; + } + + $self ??= clone $this; + $self->subjectResults[$exprString] = WeakReference::create($result); + } + } + + return $self ?? $this; + } + /** * A copy of this with the other's alternative-form entries - for the * composition tails that rebuild a SpecifiedTypes from the sure/sure-not From 2172e3633fd821beb5699b7452e75f4c945596c9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 16:47:50 +0200 Subject: [PATCH 344/378] Consume or cache-price closure arguments in on-demand call re-walks An on-demand re-walk of a call used to re-run processClosureNode for its closure arguments - the whole by-ref convergence loop, several body walks per ask. The re-walk now consumes the main walk's stored closure result, and when the per-body release already dropped it, prices the closure through getClosureType's per-node cache: one body walk on miss, none on repeat asks. Arrow function arguments get the same treatment. --- src/Analyser/ExpressionResult.php | 51 ++++- src/Analyser/NodeScopeResolver.php | 300 +++++++++++++++++------------ 2 files changed, 221 insertions(+), 130 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 6ed5e7804e5..7f16d1acb75 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,8 +2,11 @@ namespace PHPStan\Analyser; +use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\ShouldNotHappenException; @@ -491,6 +494,13 @@ public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNati if ($scope === $this->beforeScope) { return true; } + // a closure's stored result IS its (by-ref converged) walk; re-walking + // it at a foreign position would re-run the whole convergence loop. Its + // body variables are not reads of the asking position, and the + // position-sensitive TYPE is computed by getClosureType at ask sites. + if ($this->expr instanceof Expr\Closure || $this->expr instanceof Expr\ArrowFunction) { + return true; + } $names = $this->getReadVariableNames(); if ($names === []) { return true; @@ -563,15 +573,40 @@ private function getReadVariableNames(): array return $this->readVariableNames; } - $names = []; - foreach ((new NodeFinder())->findInstanceOf([$this->expr], Expr\Variable::class) as $variable) { - if (!is_string($variable->name) || $variable->name === 'this') { - continue; + $visitor = new class extends NodeVisitorAbstract { + + /** @var array */ + public array $names = []; + + #[\Override] + public function enterNode(Node $node): ?int + { + if ($node instanceof Expr\Variable && is_string($node->name) && $node->name !== 'this') { + $this->names[$node->name] = true; + } + // a closure body's variables live in its own scope - only the + // use() clause reads the enclosing position. Arrow functions + // capture implicitly and are traversed. + if ($node instanceof Expr\Closure) { + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + $this->names[$use->var->name] = true; + } + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + return null; } - $names[$variable->name] = true; - } - return $this->readVariableNames = array_keys($names); + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse([$this->expr]); + + return $this->readVariableNames = array_keys($visitor->names); } } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 24d1b0a4085..905a167df8f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4257,151 +4257,207 @@ public function processArgs( } if ($arg->value instanceof Expr\Closure) { - $restoreThisScope = null; - if ( - $closureBindScopeFactory === null - && $parameter instanceof ExtendedParameterReflection - && !$arg->value->static - ) { - $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($closureThisType !== null) { - $restoreThisScope = $scopeToPass; - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + + $storedClosureArgResult = null; + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { + // an on-demand re-walk of the enclosing call must not re-run the + // closure's whole by-ref convergence: consume the main walk's + // stored result, or (when the body release already dropped it) + // price the closure through getClosureType's per-node cache - + // a single body walk on miss, none on repeat asks + $storedClosureArgResult = $storage->findExpressionResult($arg->value); + if ($storedClosureArgResult === null) { + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $storedClosureArgResult = $this->expressionResultFactory->create( + $scopeToPass, + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $this->storeExpressionResult($storage, $arg->value, $storedClosureArgResult); } } + if ($storedClosureArgResult !== null) { + $argResults[spl_object_id($arg->value)] = $storedClosureArgResult; + } else { $restoreThisScope = null; + if ( + $closureBindScopeFactory === null + && $parameter instanceof ExtendedParameterReflection + && !$arg->value->static + ) { + $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($closureThisType !== null) { + $restoreThisScope = $scopeToPass; + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + } + } - if ($parameter !== null) { - $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($overwritingParameterType !== null) { - $parameterType = $overwritingParameterType; + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } } - } - $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null, $parameterNativeType); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { - $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); - } + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); + $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null, $parameterNativeType); + if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); + } - $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); - $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( - $closureResult->getScope(), - $scopeToPass, - $arg->value, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - type: $closureTypeResolver->buildClosureTypeForClosure( + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( + $closureResult->getScope(), $scopeToPass, $arg->value, - $closureResult->getGatheredReturnStatements(), - $closureResult->getGatheredYieldStatements(), - $closureResult->getExecutionEnds(), - $closureResult->getThrowPoints(), - $closureResult->getClosureTypeImpurePoints(), - $closureResult->getInvalidateExpressions(), - ), - nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), - typeCallback: null, - specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), - )); - - $uses = []; - foreach ($arg->value->uses as $use) { - if (!is_string($use->var->name)) { - continue; - } + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->buildClosureTypeForClosure( + $scopeToPass, + $arg->value, + $closureResult->getGatheredReturnStatements(), + $closureResult->getGatheredYieldStatements(), + $closureResult->getExecutionEnds(), + $closureResult->getThrowPoints(), + $closureResult->getClosureTypeImpurePoints(), + $closureResult->getInvalidateExpressions(), + ), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + )); - $uses[] = $use->var->name; - } - - $scope = $closureResult->getScope(); - $deferredByRefClosureResults[] = $closureResult; - // Prefer the invalidate expressions collected on the ClosureType: those - // are gathered with the closure's pending fibers flushed, so they also - // cover writes that go through a parked fiber (e.g. $this->prop[] = ...), - // unlike $closureResult->getInvalidateExpressions(). - $closureExprType = $scope->getType($arg->value); - $invalidateExpressions = $closureExprType instanceof ClosureType - ? $closureExprType->getInvalidateExpressions() - : $closureResult->getInvalidateExpressions(); - if ($restoreThisScope !== null) { - $nodeFinder = new NodeFinder(); - $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; - foreach ($invalidateExpressions as $j => $invalidateExprNode) { - $foundThis = $nodeFinder->findFirst([$invalidateExprNode->getExpr()], $cb); - if ($foundThis === null) { + $uses = []; + foreach ($arg->value->uses as $use) { + if (!is_string($use->var->name)) { continue; } - unset($invalidateExpressions[$j]); + $uses[] = $use->var->name; } - $invalidateExpressions = array_values($invalidateExpressions); - $scope = $scope->restoreThis($restoreThisScope); - } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; - } + $scope = $closureResult->getScope(); + $deferredByRefClosureResults[] = $closureResult; + // Prefer the invalidate expressions collected on the ClosureType: those + // are gathered with the closure's pending fibers flushed, so they also + // cover writes that go through a parked fiber (e.g. $this->prop[] = ...), + // unlike $closureResult->getInvalidateExpressions(). + $closureExprType = $scope->getType($arg->value); + $invalidateExpressions = $closureExprType instanceof ClosureType + ? $closureExprType->getInvalidateExpressions() + : $closureResult->getInvalidateExpressions(); + if ($restoreThisScope !== null) { + $nodeFinder = new NodeFinder(); + $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; + foreach ($invalidateExpressions as $j => $invalidateExprNode) { + $foundThis = $nodeFinder->findFirst([$invalidateExprNode->getExpr()], $cb); + if ($foundThis === null) { + continue; + } + + unset($invalidateExpressions[$j]); + } + $invalidateExpressions = array_values($invalidateExpressions); + $scope = $scope->restoreThis($restoreThisScope); + } + + if ($this->shouldInvalidateCallbackExpressions($parameter)) { + $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; + } + } } elseif ($arg->value instanceof Expr\ArrowFunction) { - if ( - $closureBindScopeFactory === null - && $parameter instanceof ExtendedParameterReflection - && !$arg->value->static - ) { - $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($closureThisType !== null) { - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + + $storedClosureArgResult = null; + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { + // see the Closure branch above - consume or price via the cache + $storedClosureArgResult = $storage->findExpressionResult($arg->value); + if ($storedClosureArgResult === null) { + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $storedClosureArgResult = $this->expressionResultFactory->create( + $scopeToPass, + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $this->storeExpressionResult($storage, $arg->value, $storedClosureArgResult); } } + if ($storedClosureArgResult !== null) { + $argResults[spl_object_id($arg->value)] = $storedClosureArgResult; + } else { if ( + $closureBindScopeFactory === null + && $parameter instanceof ExtendedParameterReflection + && !$arg->value->static + ) { + $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($closureThisType !== null) { + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + } + } - if ($parameter !== null) { - $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($overwritingParameterType !== null) { - $parameterType = $overwritingParameterType; + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } } - } - $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null, $parameterNativeType); - $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); - $argResults[spl_object_id($arg->value)] = $arrowFunctionExprResult; - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { - $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); - } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $arrowFunctionType = $scope->getType($arg->value); - if ($arrowFunctionType instanceof ClosureType) { - $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); + $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null, $parameterNativeType); + $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); + $argResults[spl_object_id($arg->value)] = $arrowFunctionExprResult; + if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); } - } - $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); - $arrowFunctionScope = $arrowFunctionResult->getArrowFunctionScope(); - $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( - $arrowFunctionExprResult->getScope(), - beforeScope: $scopeToPass, - expr: $arg->value, - hasYield: $arrowFunctionExprResult->hasYield(), - isAlwaysTerminating: $arrowFunctionExprResult->isAlwaysTerminating(), - throwPoints: $arrowFunctionExprResult->getThrowPoints(), - impurePoints: $arrowFunctionExprResult->getImpurePoints(), - type: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( - $scopeToPass, - $arg->value, - $arrowFunctionScope, - $arrowFunctionResult->getClosureTypeThrowPoints(), - $arrowFunctionResult->getClosureTypeImpurePoints(), - $arrowFunctionResult->getInvalidateExpressions(), - ), - nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), - typeCallback: null, - specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), - )); + if ($this->shouldInvalidateCallbackExpressions($parameter)) { + $arrowFunctionType = $scope->getType($arg->value); + if ($arrowFunctionType instanceof ClosureType) { + $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; + } + } + $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $arrowFunctionScope = $arrowFunctionResult->getArrowFunctionScope(); + $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( + $arrowFunctionExprResult->getScope(), + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: $arrowFunctionExprResult->hasYield(), + isAlwaysTerminating: $arrowFunctionExprResult->isAlwaysTerminating(), + throwPoints: $arrowFunctionExprResult->getThrowPoints(), + impurePoints: $arrowFunctionExprResult->getImpurePoints(), + type: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ), + nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + )); + } } else { $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { From 35eddf159d05b570dfdc0219ce0307d052b88314 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 6 Jul 2026 10:58:55 +0200 Subject: [PATCH 345/378] Skip the synthetic constructor-call pricing when the return type cannot be never resolveReturnType()'s exact-instantiation path priced a synthetic StaticCall to the constructor on demand for every `new X()`, purely to detect a constructor whose own return type resolves to explicit never. The dynamic static-method return type extensions already ran just above, so only the base constructor return type is left to check - and a plain void/object constructor can never be never. Gate the synthetic pricing on the return type being a NeverType or late-resolvable (conditional/template). On NodeScopeResolver.php self-analysis the Scope::getType bridge's on-demand re-pricings drop from 2697 to 1628 (the whole 1072-strong synthetic-StaticCall category disappears). --- src/Analyser/ExprHandler/NewHandler.php | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 9f12ee8ad26..dedfbfc8f6d 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -492,12 +492,21 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } - // $methodCall is a synthetic StaticCall the handler built - it is not a - // source node, so Scope::getType() prices it on demand (the constructor's - // own never-returning conditional return type). - $methodResult = $scope->getType($methodCall); - if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { - return $methodResult; + // A constructor makes `new` never-returning only when its own return type + // is (or can resolve to) explicit never; the dynamic static-method return + // type extensions already ran above, so only the base return type is left + // to check. Pricing the synthetic StaticCall on demand for this is + // expensive and pointless for the overwhelmingly common plain + // void/object constructor - skip it unless the return type could be never. + $constructorReturnType = $parametersAcceptor->getReturnType(); + if ($constructorReturnType instanceof NeverType || $constructorReturnType->hasTemplateOrLateResolvableType()) { + // $methodCall is a synthetic StaticCall the handler built - it is not + // a source node, so Scope::getType() prices it on demand (the + // constructor's own never-returning conditional return type). + $methodResult = $scope->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return $methodResult; + } } $objectType = $isStatic ? new StaticType($classReflection) : new ObjectType($resolvedClassName, classReflection: $classReflection); From 8c824db01c3190f54f667b819be3f3bf47fb8a7e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 17:13:17 +0200 Subject: [PATCH 346/378] Derive argument-less call subjects' narrowing base from reflection state @phpstan-assert subjects are synthetic per-build nodes (never stored), so every narrowing application walked them on demand - re-processing their receivers each time. An argument-less instance call's base is its declared return type on the receiver's state, derived in resolveScopeStateType like the other narrowable shapes. The boolean-decomposition tails now keep the whole SpecifiedTypes (withoutConditionalExpressionHolders) instead of rebuilding from the sure slots, so carried state survives composition; the rebuild-only helpers became dead and are removed. --- .../Helper/BooleanNarrowingHelper.php | 16 +------- src/Analyser/MutatingScope.php | 28 +++++++++++++ src/Analyser/SpecifiedTypes.php | 39 +++++++------------ 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php index e24fb3f9739..921cb6e47ff 100644 --- a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -107,13 +107,7 @@ public function specifyConjunction( $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); } } - $result = (new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types)->setDeferredAugments($types->getDeferredAugments()); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } + $result = $types->withoutConditionalExpressionHolders(); $recipes = [ $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, false, true, $rightScope, $rightExpr), $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, false, true, null, $leftExpr), @@ -200,13 +194,7 @@ public function specifyDisjunction( } if ($context->true()) { - $result = (new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types)->setDeferredAugments($types->getDeferredAugments()); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } + $result = $types->withoutConditionalExpressionHolders(); $recipes = [ $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, false, false, $rightScope, $rightExpr), $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, false, false, null, $leftExpr), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c1c4994cddf..b19d3bdb849 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -57,6 +57,7 @@ use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Reflection\PropertyReflection; @@ -1250,6 +1251,10 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr, ?ExpressionResult $s || $expr instanceof PropertyFetch || $expr instanceof Expr\ArrayDimFetch || $expr instanceof Expr\StaticPropertyFetch + // argument-less instance calls: the shape @phpstan-assert subjects + // take (synthetic per-build nodes, never stored - a walk per + // application otherwise) + || ($expr instanceof Expr\MethodCall && $expr->name instanceof Identifier && !$expr->isFirstClassCallable() && $expr->getArgs() === []) ) { return [ $this->resolveScopeStateType($expr, $this->nativeTypesPromoted), @@ -3177,6 +3182,29 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type return $propertyReflection->getReadableType(); } + // an argument-less instance call - the shape @phpstan-assert subjects + // take (synthetic nodes built fresh from the assert tag, never stored): + // its declared return type on the receiver's state is the narrowing + // base, derived from reflection instead of walking the synthetic node + if ( + $expr instanceof Expr\MethodCall + && $expr->name instanceof Identifier + && !$expr->isFirstClassCallable() + && $expr->getArgs() === [] + ) { + $methodReflection = $this->getMethodReflection( + $this->resolveScopeStateType($expr->var, $native), + $expr->name->toString(), + ); + if ($methodReflection === null) { + return new ErrorType(); + } + + $variant = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants()); + + return $native ? $variant->getNativeReturnType() : $variant->getReturnType(); + } + // genuinely non-narrowed expressions (constants, calls, ...) have no // variable-callback hazard, so read them normally. return $native ? $this->getNativeType($expr) : $this->getType($expr); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index b1cf855dbfe..4cbb99fcca3 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -166,17 +166,6 @@ public function withDeferredAugment(DeferredSpecifiedTypesAugment $augment): sel return $self; } - /** - * @param list $augments - */ - public function setDeferredAugments(array $augments): self - { - $self = clone $this; - $self->deferredAugments = $augments; - - return $self; - } - /** * @return list */ @@ -234,6 +223,21 @@ public function withSubjectResult(string $exprString, ExpressionResult $result): * an ExpressionResult carries itself into the narrowing its own callbacks * produced, without a printer. */ + /** + * A copy without conditional-expression holders and holder recipes - for + * the boolean-decomposition tails that replace them with freshly built + * recipes while keeping everything else (entries, alternatives, augments, + * carried subject results) intact. + */ + public function withoutConditionalExpressionHolders(): self + { + $self = clone $this; + $self->newConditionalExpressionHolders = []; + $self->conditionalExpressionHolderRecipes = []; + + return $self; + } + public function withSubjectResultForExprNode(Expr $expr, ExpressionResult $result): self { $self = null; @@ -251,19 +255,6 @@ public function withSubjectResultForExprNode(Expr $expr, ExpressionResult $resul return $self ?? $this; } - /** - * A copy of this with the other's alternative-form entries - for the - * composition tails that rebuild a SpecifiedTypes from the sure/sure-not - * slots and must not drop the merged alternatives. - */ - public function withAlternativeTypesOf(self $other): self - { - $self = clone $this; - $self->alternativeTypes = $other->alternativeTypes; - - return $self; - } - public function shouldOverwrite(): bool { return $this->overwrite; From 37254f7ed9d09eb448e02fa14a3bd4f6a327c7a1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 17:21:14 +0200 Subject: [PATCH 347/378] Revert carrying narrowing subjects' ExpressionResults in SpecifiedTypes Reverts b7ff1e15ad and 985c30942d: the carried weak references were dead exactly where the walks happened, and the assert-subject reflection read solved that class properly. The clone-based SpecifiedTypes copy methods and withoutConditionalExpressionHolders() stay. --- .../Helper/DefaultNarrowingHelper.php | 6 -- src/Analyser/ExpressionResult.php | 10 +-- src/Analyser/MutatingScope.php | 13 +--- src/Analyser/SpecifiedTypes.php | 62 +------------------ 4 files changed, 6 insertions(+), 85 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index c925e4af955..403c5d9ccc5 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -244,12 +244,6 @@ public function createSubjectTypesFromResultState(MutatingScope $s, Expr $subjec } $result = new SpecifiedTypes($sureTypes, $sureNotTypes); - if ($subjectResult !== null && ($sureTypes !== [] || $sureNotTypes !== [])) { - // carry the subject's result so applySpecifiedTypes() reads its - // current type pair through it instead of pricing the node on - // demand when the applying scope's storage no longer sees it - $result = $result->withSubjectResult($this->exprPrinter->printExpr($subject), $subjectResult); - } if (isset($nullsafeFanTypes)) { $result = $result->unionWith($nullsafeFanTypes); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7f16d1acb75..67dc1d8f0b8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -402,11 +402,7 @@ public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTyp { $key = (spl_object_id($context) << 1) | ($nativeTypesPromoted ? 1 : 0); - // entries keyed by this very node carry this result, so - // applySpecifiedTypes() reads the subject's current pair through it - // instead of pricing the node on demand - return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted) - ->withSubjectResultForExprNode($this->expr, $this); + return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted); } /** @@ -435,9 +431,7 @@ public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool return null; } - // see getSpecifiedTypes() - the subject carries itself - return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted) - ->withSubjectResultForExprNode($this->expr, $this); + return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted); } /** diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b19d3bdb849..0f7dc0f97b1 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1233,7 +1233,7 @@ private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type * * @return array{Type, Type}|null */ - private function getCurrentTypesOfSpecifiedExpr(Expr $expr, ?ExpressionResult $subjectResult = null): ?array + private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array { $storage = $this->expressionResultStorageStack->getCurrent(); if ($storage === null) { @@ -1264,15 +1264,6 @@ private function getCurrentTypesOfSpecifiedExpr(Expr $expr, ?ExpressionResult $s $result = $storage->findExpressionResult($expr); if ($result === null) { - // a subject result captured where the narrowing was composed answers - // like a storage hit would - no walk - if ($subjectResult !== null) { - return [ - $subjectResult->getTypeOnScope($this, $this->nativeTypesPromoted), - $subjectResult->getTypeOnScope($this, true), - ]; - } - // a call subject (or a synthetic plain-chain variant) is priced on // demand once per scope: one walk answers both flavours, and the // truthy and falsey applications of one narrowing - and every later @@ -3677,7 +3668,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $trackedNativeType = $scope->nativeExpressionTypes[$exprString]->getType(); } if ($trackedType === null) { - $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr, $specifiedTypes->getSubjectResult($exprString)); + $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr); if ($currentTypes !== null) { if ($scope->isComplexUnionType($currentTypes[0])) { continue; diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 4cbb99fcca3..7c25fa2e938 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -4,7 +4,6 @@ use Closure; use PhpParser\Node\Expr; -use WeakReference; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -54,20 +53,6 @@ final class SpecifiedTypes */ private array $alternativeTypes = []; - /** - * The narrowing subjects' ExpressionResults, captured where the narrowing - * was composed - applySpecifiedTypes() reads a subject's current type pair - * through its result instead of pricing the node on demand when the - * applying scope's storage no longer sees it. Weakly referenced: a result - * attaches ITSELF to its own narrowing (getSpecifiedTypes memoizes that - * narrowing on the result), so a strong reference would be a refcount - * self-cycle that gc_disable() never collects - and a subject released - * with its body simply falls back to the on-demand pricing. - * - * @var array> - */ - private array $subjectResults = []; - /** * @api * @param array $sureTypes @@ -200,34 +185,11 @@ public function getAlternativeTypes(): array return $this->alternativeTypes; } - /** The narrowing subject's ExpressionResult captured at compose time. */ - public function getSubjectResult(string $exprString): ?ExpressionResult - { - if (!isset($this->subjectResults[$exprString])) { - return null; - } - - return $this->subjectResults[$exprString]->get(); - } - - public function withSubjectResult(string $exprString, ExpressionResult $result): self - { - $self = clone $this; - $self->subjectResults[$exprString] = WeakReference::create($result); - - return $self; - } - - /** - * Attaches the result to every entry whose subject IS the given node - how - * an ExpressionResult carries itself into the narrowing its own callbacks - * produced, without a printer. - */ /** * A copy without conditional-expression holders and holder recipes - for * the boolean-decomposition tails that replace them with freshly built - * recipes while keeping everything else (entries, alternatives, augments, - * carried subject results) intact. + * recipes while keeping everything else (entries, alternatives, augments) + * intact. */ public function withoutConditionalExpressionHolders(): self { @@ -238,23 +200,6 @@ public function withoutConditionalExpressionHolders(): self return $self; } - public function withSubjectResultForExprNode(Expr $expr, ExpressionResult $result): self - { - $self = null; - foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes] as $entries) { - foreach ($entries as $exprString => $entry) { - if ($entry[0] !== $expr || isset($this->subjectResults[$exprString])) { - continue; - } - - $self ??= clone $this; - $self->subjectResults[$exprString] = WeakReference::create($result); - } - } - - return $self ?? $this; - } - public function shouldOverwrite(): bool { return $this->overwrite; @@ -279,7 +224,6 @@ public function removeExpr(string $exprString): self unset($self->sureTypes[$exprString]); unset($self->sureNotTypes[$exprString]); unset($self->alternativeTypes[$exprString]); - unset($self->subjectResults[$exprString]); return $self; } @@ -355,7 +299,6 @@ public function intersectWith(SpecifiedTypes $other): self $result = new self($sureTypeUnion, $sureNotTypeUnion); $result->alternativeTypes = $alternativeUnion; - $result->subjectResults = $this->subjectResults + $other->subjectResults; if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -436,7 +379,6 @@ public function unionWith(SpecifiedTypes $other): self $result = new self($sureTypeUnion, $sureNotTypeUnion); $result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes; - $result->subjectResults = $this->subjectResults + $other->subjectResults; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } From 09e553653f737ee33c7741b63d19432694181fc7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 18:45:35 +0200 Subject: [PATCH 348/378] Compose ??= from its child results like CoalesceHandler The Coalesce arm of AssignOpHandler priced everything on demand: the typeCallback walked the lvalue on a fresh storage and re-walked a synthetic BinaryOp\Coalesce (both flavours), the value-expr scope was narrowed by walking a synthetic NotIdentical, and the CoalesceExpressionNode walked the lvalue on demand once more - around six on-demand walks per processing, multiplied by loop convergence (the census hot spot for $cache[$key] ??= inside nested loops). Now the left side is priced once as a real read (with the isset descriptor, mirroring CoalesceHandler), and everything composes from that result: CoalesceCompositionHelper::composeType in the typeCallback, getFalseySpecifiedTypes for the value-expr branch scope, and the read result carried on the CoalesceExpressionNode. The handler's pre-store of its own result is now a provisional store: rule fibers waiting on the expression are no longer resumed by it (the composed typeCallback is only complete once the value expr is processed); they resume on the final store at the end of processExprNode. --- src/Analyser/ExprHandler/AssignOpHandler.php | 147 ++++++++++-------- src/Analyser/Fiber/FiberNodeScopeResolver.php | 6 +- src/Analyser/NodeScopeResolver.php | 26 ++++ 3 files changed, 113 insertions(+), 66 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 40a8321a4d6..53a0e38fde7 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -6,17 +6,16 @@ use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\BinaryOp; -use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; +use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -48,6 +47,8 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private NonNullabilityHelper $nonNullabilityHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -61,11 +62,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; - if ( - !$expr instanceof Expr\AssignOp\Coalesce - && ($expr->var instanceof Expr\Variable - || $expr->var instanceof Expr\PropertyFetch - || $expr->var instanceof Expr\StaticPropertyFetch) + $condResult = null; + $chainResults = []; + $rightResult = null; + if ($expr instanceof Expr\AssignOp\Coalesce) { + // `$lvalue ??= ...` is `$lvalue = $lvalue ?? ...`: price the left side + // once as a read (mirroring CoalesceHandler's left-side processing, with + // the isset descriptor - bug-13623) and compose everything off that + // result. The NoopNodeCallback avoids duplicate reports and the read's + // scope is discarded: processAssignVar() walks the target itself. + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->var); + $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->var); + $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $condScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $this->defaultNarrowingHelper->captureChainResults($expr->var, $storage, $chainResults); + } elseif ( + $expr->var instanceof Expr\Variable + || $expr->var instanceof Expr\PropertyFetch + || $expr->var instanceof Expr\StaticPropertyFetch ) { // `$lvalue OP= ...` reads the old value of `$lvalue`; processAssignVar() // processes a Variable/property target only as an assignment target, never @@ -79,27 +93,63 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); } - $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): Type { + $processValueExpr = function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, $condResult, &$rightResult): ExpressionResult { + $originalScope = $scope; + if ($expr instanceof Expr\AssignOp\Coalesce) { + // the value expr only evaluates when the left side is null - the + // falsey narrowing of the coalesce, composed from the left read + $scope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $scope, $expr->var, $condResult, $expr, TypeSpecifierContext::createFalsey())); + + if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $context = $context->enterRightSideAssign( + $expr->var->name, + $expr->expr, + ); + } + } + + $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + if ($expr instanceof Expr\AssignOp\Coalesce) { + $rightResult = $exprResult; + $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $condResult->getType()->isNull()->yes(); + return $this->expressionResultFactory->create( + $exprResult->getScope()->mergeWith($originalScope), + $originalScope, + $expr->expr, + $exprResult->hasYield(), + $isAlwaysTerminating, + $exprResult->getThrowPoints(), + $exprResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + } + + return $exprResult; + }; + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope, $condResult, $chainResults, &$rightResult): Type { // $expr->var and $expr->expr were processed during this handler's // processExpr (the var as the assignment target, the value expr by the - // inner closure below), so their ExpressionResults are stored - read - // them instead of re-walking via Scope::getType(). + // $processValueExpr closure above), so their ExpressionResults are + // stored - read them instead of re-walking via Scope::getType(). $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); if ($expr instanceof Expr\AssignOp\Coalesce) { - // The coalesce is synthetic; price it on demand. The ??= left is stored - // as an assignment target (no isset descriptor), so inject a read result - // of it (with the descriptor) - otherwise the coalesce resolves a - // descriptor-less leaf that reads as definitely-set and drops the `??` - // branch, losing the optional offset natively (bug-13623). - $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); - $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); - $coalesceStorage = ($beforeScope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); - $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); - - $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); - - return $nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); + if ($rightResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->coalesceCompositionHelper->composeType( + $nodeScopeResolver, + $expr->var, + $condResult, + $rightResult, + $beforeScope, + $chainResults, + $expr, + $nativeTypesPromoted, + ); } if ($expr instanceof Expr\AssignOp\Concat) { @@ -157,17 +207,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr instanceof Expr\AssignOp\Coalesce) { // a type constraint on `$x ??= y` constrains the assigned variable - // what TypeSpecifier::create() recovered by its AssignOp\Coalesce arm - $createTypesCallback = function (Type $constraintType, TypeSpecifierContext $cctx, bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + $createTypesCallback = function (Type $constraintType, TypeSpecifierContext $cctx, bool $nativeTypesPromoted) use ($expr, $condResult, $beforeScope): SpecifiedTypes { $cs = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; - return $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $nodeScopeResolver->findStoredResult($expr->var, $cs), $constraintType, $cctx); + return $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $condResult, $constraintType, $cctx); }; } // processAssignVar asks getType($expr) for the value to assign; store this // result first so it resolves from the typeCallback above rather than - // re-processing the node on demand (which would recurse). - $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + // re-processing the node on demand (which would recurse). Provisional: + // the typeCallback is only complete once the value expr is processed. + $nodeScopeResolver->storeProvisionalExpressionResult($storage, $expr, $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, expr: $expr, @@ -189,37 +240,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { - $originalScope = $scope; - if ($expr instanceof Expr\AssignOp\Coalesce) { - $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); - - if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $context = $context->enterRightSideAssign( - $expr->var->name, - $expr->expr, - ); - } - } - - $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - if ($expr instanceof Expr\AssignOp\Coalesce) { - $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $originalScope)->isNull()->yes(); - return $this->expressionResultFactory->create( - $exprResult->getScope()->mergeWith($originalScope), - $originalScope, - $expr->expr, - $exprResult->hasYield(), - $isAlwaysTerminating, - $exprResult->getThrowPoints(), - $exprResult->getImpurePoints(), - typeCallback: static fn () => new MixedType(), - specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), - ); - } - - return $exprResult; - }, + $processValueExpr, $expr instanceof Expr\AssignOp\Coalesce, ); $scope = $assignResult->getScope(); @@ -237,12 +258,8 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } - if ($expr instanceof Expr\AssignOp\Coalesce) { - // the ??= left side is processed as an assignment target, not a read, so - // it carries no isset descriptor; read it on demand so NullCoalesceRule - // gets the chain's IssetabilityResolution off the carried result - $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $varReadResult, 'on left side of ??='), $beforeScope, $storage, $context); + if ($expr instanceof Expr\AssignOp\Coalesce && $condResult !== null) { + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??='), $beforeScope, $storage, $context); } return $this->expressionResultFactory->create( diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 6e7d2cd8df8..c1497565ebc 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -76,7 +76,11 @@ private function runFiberForNodeCallback( { while (!$fiber->isTerminated()) { if ($request instanceof ExpressionResultRequest) { - $expressionResult = $storage->findExpressionResult($request->expr); + // a provisional pre-store is not an answer for outside askers - + // park until the handler stores the final result + $expressionResult = isset($this->provisionalExprIds[spl_object_id($request->expr)]) + ? null + : $storage->findExpressionResult($request->expr); if ($expressionResult !== null) { $request = $fiber->resume($expressionResult); continue; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 905a167df8f..8846c802189 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -234,6 +234,14 @@ class NodeScopeResolver */ protected array $processingExprIds = []; + /** + * Expressions whose currently stored result is a handler's mid-processing + * pre-store - see storeProvisionalExpressionResult(). + * + * @var array + */ + protected array $provisionalExprIds = []; + /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ public static bool $guardNewWorld = false; @@ -438,11 +446,29 @@ public function storeExpressionResult(ExpressionResultStorage $storage, Expr $ex if (self::$guardNewWorld) { self::$guardProcessedExprIds[spl_object_id($expr)] = true; } + unset($this->provisionalExprIds[spl_object_id($expr)]); // handlers are answered from stored results in both worlds - storing must // not depend on fibers $storage->storeExpressionResult($expr, $expressionResult); } + /** + * Stores a handler's mid-processing result for its own expression - consumed + * synchronously (processAssignVar reading the value to assign) but incomplete + * for outside askers (e.g. the ??= composition needs the value expr's result, + * which is only processed later in the same processExpr). Rule fibers waiting + * on the expression are not resumed by this store; they resume when the final + * result lands via storeExpressionResult(). + */ + public function storeProvisionalExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void + { + if (self::$guardNewWorld) { + self::$guardProcessedExprIds[spl_object_id($expr)] = true; + } + $this->provisionalExprIds[spl_object_id($expr)] = true; + $storage->storeExpressionResult($expr, $expressionResult); + } + protected function processPendingFibers(ExpressionResultStorage $storage): void { } From 03a5a0234f478dd9d6451f488dee5c41deb080ce Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 18:56:08 +0200 Subject: [PATCH 349/378] Store the post-inc/dec synthetic's result before the virtual assign PostInc/PostDec passed a fresh synthetic PreInc/PreDec into processVirtualAssign without storing a result for it, so processAssignVar's stored-result reads missed and fell back to on-demand pricing: the written type walked the synthetic, the truthy/falsey narrowing walked it twice more, and each surviving falsey sentinel walked synthetic NotIdentical/Identical comparisons - around five on-demand walks per $i++ whose type includes a falsey value, multiplied by loop convergence. The increment/decrement written-type logic moves from PreIncHandler and PreDecHandler into a shared IncDecTypeHelper; the post handlers use it to store the synthetic's result up front, taking the same composed path as the pre handlers. The pre handlers' pre-store becomes provisional (see storeProvisionalExpressionResult) - it exists for processAssignVar's internal reads, and rule fibers resume on the final store. --- phpstan-baseline.neon | 2 +- .../ExprHandler/Helper/IncDecTypeHelper.php | 127 ++++++++++++++++++ src/Analyser/ExprHandler/PostDecHandler.php | 20 ++- src/Analyser/ExprHandler/PostIncHandler.php | 20 ++- src/Analyser/ExprHandler/PreDecHandler.php | 84 +----------- src/Analyser/ExprHandler/PreIncHandler.php | 85 +----------- 6 files changed, 178 insertions(+), 160 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 18510129a98..75eab4fc421 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -52,7 +52,7 @@ parameters: rawMessage: 'Only numeric types are allowed in pre-increment, float|int|string|null given.' identifier: preInc.nonNumeric count: 1 - path: src/Analyser/ExprHandler/PreIncHandler.php + path: src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php - rawMessage: Casting to string something that's already string. diff --git a/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php b/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php new file mode 100644 index 00000000000..2e9a3766c1d --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php @@ -0,0 +1,127 @@ +getNativeType() : $varResult->getType()); + $varScalars = $varType->getConstantScalarValues(); + + if (count($varScalars) > 0) { + $newTypes = []; + + foreach ($varScalars as $varValue) { + if ($increment) { + if ($varValue === '') { + $varValue = '1'; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_increment($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (!is_bool($varValue)) { + ++$varValue; + } + } else { + if ($varValue === '') { + $varValue = -1; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_decrement($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (is_numeric($varValue)) { + --$varValue; + } + } + + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); + } + return TypeCombinator::union(...$newTypes); + } elseif ($varType->isString()->yes()) { + if ($varType->isLiteralString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); + } + + if ($varType->isNumericString()->yes()) { + return new BenevolentUnionType([ + new IntegerType(), + new FloatType(), + ]); + } + + return new BenevolentUnionType([ + new StringType(), + new IntegerType(), + new FloatType(), + ]); + } + + $one = new Int_(1); + $getType = static function (Expr $e) use ($nativeTypesPromoted, $varExpr, $varResult, $one): Type { + if ($e === $varExpr) { + return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + } + if ($e === $one) { + return new ConstantIntegerType(1); + } + + throw new ShouldNotHappenException(); + }; + + return $increment + ? $this->initializerExprTypeResolver->getPlusType($varExpr, $one, $getType) + : $this->initializerExprTypeResolver->getMinusType($varExpr, $one, $getType); + }; + } + +} diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index c37612c6c75..0454ec53414 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -29,6 +30,7 @@ final class PostDecHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IncDecTypeHelper $incDecTypeHelper, ) { } @@ -42,13 +44,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the virtual assign writes the decremented value; store the synthetic's + // result up front so processAssignVar composes off it instead of pricing + // the unprocessed synthetic (and sentinel comparisons against it) on demand + $virtualExpr = new PreDec($expr->var); + $nodeScopeResolver->storeExpressionResult($storage, $virtualExpr, $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $virtualExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, false), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), + )); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, $stmt, $expr->var, - new PreDec($expr->var), + $virtualExpr, $nodeCallback, )->getScope(), beforeScope: $scope, diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index e35d06ec32f..2a6768d0a24 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; @@ -29,6 +30,7 @@ final class PostIncHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private IncDecTypeHelper $incDecTypeHelper, ) { } @@ -42,13 +44,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the virtual assign writes the incremented value; store the synthetic's + // result up front so processAssignVar composes off it instead of pricing + // the unprocessed synthetic (and sentinel comparisons against it) on demand + $virtualExpr = new PreInc($expr->var); + $nodeScopeResolver->storeExpressionResult($storage, $virtualExpr, $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $virtualExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, true), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), + )); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, $stmt, $expr->var, - new PreInc($expr->var), + $virtualExpr, $nodeCallback, )->getScope(), beforeScope: $scope, diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 569996ab484..7ce8d8cf7b7 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -4,7 +4,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\PreDec; -use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -12,29 +11,13 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Reflection\InitializerExprTypeResolver; -use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\AccessoryLiteralStringType; -use PHPStan\Type\BenevolentUnionType; -use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\ConstantTypeHelper; -use PHPStan\Type\FloatType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NeverType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use ValueError; -use function count; -use function is_numeric; -use function is_string; -use function str_decrement; /** * @implements ExprHandler @@ -45,7 +28,7 @@ final class PreDecHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private InitializerExprTypeResolver $initializerExprTypeResolver, + private IncDecTypeHelper $incDecTypeHelper, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -60,69 +43,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { - $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = -1; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_decrement($varValue); - } catch (ValueError) { - return new NeverType(); - } - } elseif (is_numeric($varValue)) { - --$varValue; - } - - $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } - - if ($varType->isNumericString()->yes()) { - return new BenevolentUnionType([ - new IntegerType(), - new FloatType(), - ]); - } - - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - $one = new Int_(1); - return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { - if ($e === $expr->var) { - return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); - } - if ($e === $one) { - return new ConstantIntegerType(1); - } - - throw new ShouldNotHappenException(); - }); - }; + $typeCallback = $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, false); $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); // processVirtualAssign asks getType($expr) for the value to assign; store // this result first so that resolves from the typeCallback below rather - // than re-processing the node on demand (which would recurse). - $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + // than re-processing the node on demand (which would recurse). Provisional: + // rule fibers resume on the final store at the end of processExprNode. + $nodeScopeResolver->storeProvisionalExpressionResult($storage, $expr, $this->expressionResultFactory->create( $varResult->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 47b62f9e81a..68fafbefa21 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -4,7 +4,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\PreInc; -use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -12,30 +11,13 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Reflection\InitializerExprTypeResolver; -use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\AccessoryLiteralStringType; -use PHPStan\Type\BenevolentUnionType; -use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\ConstantTypeHelper; -use PHPStan\Type\FloatType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NeverType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use ValueError; -use function count; -use function is_bool; -use function is_numeric; -use function is_string; -use function str_increment; /** * @implements ExprHandler @@ -46,7 +28,7 @@ final class PreIncHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, - private InitializerExprTypeResolver $initializerExprTypeResolver, + private IncDecTypeHelper $incDecTypeHelper, private DefaultNarrowingHelper $defaultNarrowingHelper, ) { @@ -61,69 +43,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { - $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = '1'; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_increment($varValue); - } catch (ValueError) { - return new NeverType(); - } - } elseif (!is_bool($varValue)) { - ++$varValue; - } - - $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } - - if ($varType->isNumericString()->yes()) { - return new BenevolentUnionType([ - new IntegerType(), - new FloatType(), - ]); - } - - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - $one = new Int_(1); - return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { - if ($e === $expr->var) { - return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); - } - if ($e === $one) { - return new ConstantIntegerType(1); - } - - throw new ShouldNotHappenException(); - }); - }; + $typeCallback = $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, true); $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); // processVirtualAssign asks getType($expr) for the value to assign; store // this result first so that resolves from the typeCallback below rather - // than re-processing the node on demand (which would recurse). - $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + // than re-processing the node on demand (which would recurse). Provisional: + // rule fibers resume on the final store at the end of processExprNode. + $nodeScopeResolver->storeProvisionalExpressionResult($storage, $expr, $this->expressionResultFactory->create( $varResult->getScope(), beforeScope: $scope, expr: $expr, From 43074b046afdee88d7f2e4fbabf1b0424d9a4f05 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 19:09:20 +0200 Subject: [PATCH 350/378] Fabricate virtual assigned-expr results instead of pricing them on demand processVirtualAssign handed processAssignVar a dummy result and never stored anything for the assigned expr, so every virtual assign (by-ref args, foreach destructuring, unset offsets, clone-with, array mutations, list items) took the stored-result miss path: the written type, the truthy/falsey narrowing, and each surviving falsey sentinel's synthetic NotIdentical/Identical comparison were all priced by on-demand walks, per loop-convergence iteration. The answers were available all along: TypeExpr/NativeTypeExpr carry their types, the unset-offset composition's chain results are stored one line above the call, and the post-inc/dec handlers already build the written type's callback. The new VirtualExprResultHelper owns these fabrications and the virtual node handlers delegate to it, so a fabricated result and a walked one are identical by construction. processVirtualAssign accepts a caller-provided result or fabricates one from a type-carrying node and stores it, putting the whole downstream machinery on the composed path. --- src/Analyser/ExprHandler/AssignHandler.php | 5 ++ .../Helper/VirtualExprResultHelper.php | 65 +++++++++++++++++++ src/Analyser/ExprHandler/PostDecHandler.php | 11 ++-- src/Analyser/ExprHandler/PostIncHandler.php | 11 ++-- .../Virtual/NativeTypeExprHandler.php | 23 +------ .../ExprHandler/Virtual/TypeExprHandler.php | 23 +------ .../Virtual/UnsetOffsetExprHandler.php | 18 +---- src/Analyser/NodeScopeResolver.php | 36 +++++++++- 8 files changed, 125 insertions(+), 67 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index da0ddbd1fbc..93f5a7c966b 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -31,6 +31,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -102,6 +103,7 @@ public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, private IdenticalNarrowingHelper $identicalNarrowingHelper, private PropertyReflectionFinder $propertyReflectionFinder, + private VirtualExprResultHelper $virtualExprResultHelper, ) { } @@ -1058,6 +1060,9 @@ public function processAssignVar( $dimExpr = $arrayItem->key; } $getOffsetValueTypeExpr = new TypeExpr($nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope))); + // store the fabricated result so the recursion's stored-result reads + // compose instead of falling back to on-demand pricing + $nodeScopeResolver->storeExpressionResult($storage, $getOffsetValueTypeExpr, $this->virtualExprResultHelper->createTypeExprResult($scope, $getOffsetValueTypeExpr)); $result = $this->processAssignVar( $nodeScopeResolver, $scope, diff --git a/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php b/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php new file mode 100644 index 00000000000..6f5eb174da1 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php @@ -0,0 +1,65 @@ +expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $expr instanceof TypeExpr + ? static fn (bool $nativeTypesPromoted): Type => $expr->getExprType() + : static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + + public function createUnsetOffsetExprResult(MutatingScope $scope, UnsetOffsetExpr $expr, ExpressionResult $varResult, ExpressionResult $dimResult): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->unsetOffset($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + } + +} diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 0454ec53414..9dd64bc8e85 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -44,11 +44,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - // the virtual assign writes the decremented value; store the synthetic's - // result up front so processAssignVar composes off it instead of pricing - // the unprocessed synthetic (and sentinel comparisons against it) on demand + // the virtual assign writes the decremented value - hand it the synthetic's + // result so processAssignVar composes off it instead of pricing the + // unprocessed synthetic (and sentinel comparisons against it) on demand $virtualExpr = new PreDec($expr->var); - $nodeScopeResolver->storeExpressionResult($storage, $virtualExpr, $this->expressionResultFactory->create( + $virtualExprResult = $this->expressionResultFactory->create( $varResult->getScope(), beforeScope: $scope, expr: $virtualExpr, @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, false), specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), - )); + ); return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( @@ -68,6 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $virtualExpr, $nodeCallback, + $virtualExprResult, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 2a6768d0a24..841a9c5a6f4 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -44,11 +44,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - // the virtual assign writes the incremented value; store the synthetic's - // result up front so processAssignVar composes off it instead of pricing - // the unprocessed synthetic (and sentinel comparisons against it) on demand + // the virtual assign writes the incremented value - hand it the synthetic's + // result so processAssignVar composes off it instead of pricing the + // unprocessed synthetic (and sentinel comparisons against it) on demand $virtualExpr = new PreInc($expr->var); - $nodeScopeResolver->storeExpressionResult($storage, $virtualExpr, $this->expressionResultFactory->create( + $virtualExprResult = $this->expressionResultFactory->create( $varResult->getScope(), beforeScope: $scope, expr: $virtualExpr, @@ -58,7 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, true), specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), - )); + ); return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( @@ -68,6 +68,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $virtualExpr, $nodeCallback, + $virtualExprResult, )->getScope(), beforeScope: $scope, expr: $expr, diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 35fbebae239..00efdb3a0f6 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -6,16 +6,13 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\NativeTypeExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -24,10 +21,7 @@ final class NativeTypeExprHandler implements ExprHandler { - public function __construct( - private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, - ) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -40,18 +34,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), - specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), - ); + return $this->virtualExprResultHelper->createTypeExprResult($scope, $expr); } } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 2e0f1053d80..0ca500b5b7d 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -6,16 +6,13 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -24,10 +21,7 @@ final class TypeExprHandler implements ExprHandler { - public function __construct( - private ExpressionResultFactory $expressionResultFactory, - private DefaultNarrowingHelper $defaultNarrowingHelper, - ) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -40,18 +34,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => $expr->getExprType(), - specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), - ); + return $this->virtualExprResultHelper->createTypeExprResult($scope, $expr); } } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index badfc48069a..7cf8741e19c 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -2,19 +2,17 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; -use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\UnsetOffsetExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -23,7 +21,7 @@ final class UnsetOffsetExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -43,17 +41,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); $dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context); - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->unsetOffset(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType())), - specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), - ); + return $this->virtualExprResultHelper->createUnsetOffsetExprResult($scope, $expr, $varResult, $dimResult); } } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8846c802189..015b38c8970 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -53,6 +53,7 @@ use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -2610,7 +2611,22 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch /** @var Expr $clonedVar */ [$clonedVar] = $traverser->traverse([$clonedVar]); - $scope = $this->processVirtualAssign($scope, $storage, $stmt, $clonedVar, new UnsetOffsetExpr($var->var, $var->dim), $nodeCallback)->getScope(); + $unsetOffsetExpr = new UnsetOffsetExpr($var->var, $var->dim); + $scope = $this->processVirtualAssign( + $scope, + $storage, + $stmt, + $clonedVar, + $unsetOffsetExpr, + $nodeCallback, + // composed from the chain results the unset target's walk just stored + $this->container->getByType(VirtualExprResultHelper::class)->createUnsetOffsetExprResult( + $scope, + $unsetOffsetExpr, + $this->readStoredResult($var->var, $storage), + $this->readStoredResult($var->dim, $storage), + ), + )->getScope(); } elseif ($var instanceof PropertyFetch) { $scope = $scope->invalidateExpression($var); $impurePoints[] = new ImpurePoint( @@ -4948,8 +4964,24 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback): ExpressionResult + public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?ExpressionResult $assignedExprResult = null): ExpressionResult { + // work off an available result for the assigned expr: passed by the + // caller, stored by natural processing, or fabricated from a + // type-carrying virtual node - so processAssignVar's stored-result reads + // compose instead of falling back to on-demand pricing of the type, the + // truthy/falsey narrowing, and the synthetic sentinel comparisons + if ( + $assignedExprResult === null + && ($assignedExpr instanceof TypeExpr || $assignedExpr instanceof NativeTypeExpr) + && $storage->findExpressionResult($assignedExpr) === null + ) { + $assignedExprResult = $this->container->getByType(VirtualExprResultHelper::class)->createTypeExprResult($scope, $assignedExpr); + } + if ($assignedExprResult !== null) { + $this->storeExpressionResult($storage, $assignedExpr, $assignedExprResult); + } + return $this->container->getByType(AssignHandler::class)->processAssignVar( $this, $scope, From 23b499dc3ba1e4bed3c327c83d8fe6eea0e0cd52 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 19:33:35 +0200 Subject: [PATCH 351/378] Add regression test for #14914 --- .../ArrowFunctionReturnTypeRuleTest.php | 5 ++++ .../Functions/ClosureReturnTypeRuleTest.php | 5 ++++ .../Rules/Functions/data/bug-14914.php | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-14914.php diff --git a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php index 23093600316..4ab72a3893c 100644 --- a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php @@ -77,4 +77,9 @@ public function testBugFunctionMethodConstants(): void $this->analyse([__DIR__ . '/data/bug-anonymous-function-method-constant.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php index c363c194d36..3a9832d5931 100644 --- a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php @@ -149,4 +149,9 @@ public function testBug13964(): void $this->analyse([__DIR__ . '/data/bug-13964.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php new file mode 100644 index 00000000000..c31cfeb52d3 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -0,0 +1,28 @@ +b)/', + function (array $match): string { + if ($match['b'] !== null) { + return 'aa'; + } + return 'possible?'; + }, + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} + +function doFoo2(): void +{ + preg_replace_callback( + '/a|(?b)/', + fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?', + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} From 5238ccecaabd53258ee62b6464535ce23a4818aa Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 19:33:35 +0200 Subject: [PATCH 352/378] Add regression test for #14908 --- tests/PHPStan/Analyser/nsrt/bug-14908.php | 44 +++++++++++++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 6 +++ ...rictComparisonOfDifferentTypesRuleTest.php | 5 +++ .../Rules/Comparison/data/bug-14908.php | 42 ++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14908.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14908.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14908.php b/tests/PHPStan/Analyser/nsrt/bug-14908.php new file mode 100644 index 00000000000..d4cb4b38e18 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14908.php @@ -0,0 +1,44 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14908Nsrt; + +use function PHPStan\Testing\assertType; + +enum Grade { case One; case Two; case Three; } +enum Kind { case K1; case K2; case K3; } + +class Flags { public bool $flagA = false; } + +function run(Kind $kind, Grade $grade, Flags $flags, bool $extra, bool $cond): void +{ + $forced = false; + if ( + $grade !== Grade::Three + && $cond + && in_array($kind, [Kind::K1, Kind::K2], true) + && $flags->flagA === true + ) { + $forced = true; + } + + // Intermediate `if` narrowing ANOTHER value (`$extra === false`) in a disjunction. + // This is the ingredient that defeats the #14807 fix. + if ( + $forced === false + && ( + ($grade === Grade::One && $extra === false) + || ($cond && $grade !== Grade::Three) + ) + ) { + throw new \Exception(); + } + + // The narrowing from the first `if` must not leak here: skipping the first + // `if` says nothing about $flags->flagA or $kind on their own. + if ($grade !== Grade::Three) { + assertType('bool', $flags->flagA); + assertType('Bug14908Nsrt\Kind', $kind); + } +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 7a74be1cde7..0ed02308e26 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1390,4 +1390,10 @@ public function testBug6211(): void ]); } + public function testBug14908(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14908.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 0d476571c8a..15d7ef64c7c 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1275,4 +1275,9 @@ public function testBug14847(): void ]); } + public function testBug14908(): void + { + $this->analyse([__DIR__ . '/data/bug-14908.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14908.php b/tests/PHPStan/Rules/Comparison/data/bug-14908.php new file mode 100644 index 00000000000..c4683dc18b0 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14908.php @@ -0,0 +1,42 @@ +flagA === true + ) { + $forced = true; + } + + // Intermediate `if` narrowing ANOTHER value (`$extra === false`) in a disjunction. + // This is the ingredient that defeats the #14807 fix. + if ( + $forced === false + && ( + ($grade === Grade::One && $extra === false) + || ($cond && $grade !== Grade::Three) + ) + ) { + throw new \Exception(); + } + + if ($grade !== Grade::Three) { + if ($flags->flagA === false) { + throw new \Exception(); + } + if (in_array($kind, [Kind::K1, Kind::K2], true)) { + echo "reachable"; + } + } +} From ff87aacb6f3ac28d7d05ec2c66a7ba386f630672 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:14:36 +0200 Subject: [PATCH 353/378] Add required lint version comments to bug-14914 and bug-14908 fixtures --- tests/PHPStan/Rules/Comparison/data/bug-14908.php | 4 +++- tests/PHPStan/Rules/Functions/data/bug-14914.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14908.php b/tests/PHPStan/Rules/Comparison/data/bug-14908.php index c4683dc18b0..4cb9727df34 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14908.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14908.php @@ -1,4 +1,6 @@ -= 8.1 + +declare(strict_types = 1); namespace Bug14908; diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php index c31cfeb52d3..5bc6a7edb6a 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-14914.php +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types = 1); namespace Bug14914; From db705ae4947f5bc21dcb40601aa2fe674e80516a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:14:36 +0200 Subject: [PATCH 354/378] Read the foreach iteratee off the scope instead of re-walking it The foreach handling re-priced its iteratee with two on-demand walks per processing: once on the truthy-narrowed scope (the iteratee narrowed to a non-empty array) and once on the post-loop scope (the body may have modified it). Both narrowing and modification are tracked by the scope, so getTypeOnScope()'s authoritative read answers without a walk; the new ExpressionResult::answersOnScope() gates the rare remainder - an untracked iteratee whose inputs the loop body changed - onto the reprocessing path. In nested loops inside closures these walks multiplied with the convergence passes (91 of the 237 walks of the census's hottest non-trait node came from these two sites). --- src/Analyser/ExpressionResult.php | 20 +++++++++++++++++ src/Analyser/NodeScopeResolver.php | 36 +++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 67dc1d8f0b8..c1f11a28b43 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -453,6 +453,26 @@ public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type return $this->resolveOwnType($useNativeTypes); } + /** + * Whether getTypeOnScope() gives the correct answer at the given (foreign) + * position without re-pricing the expression there: the answer is + * position-independent (eager type), the scope owns it (tracked variable or + * expression - including narrowing and invalidation of this very + * expression), or nothing the expression reads changed since the walk. + * When this is false, the caller must reprocess the expression on the + * asking scope. + */ + public function answersOnScope(MutatingScope $scope, bool $useNativeTypes): bool + { + if ($this->type !== null) { + return true; + } + + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + + return $this->isScopeAuthoritative($readScope) || $this->askScopeVariableStateMatches($scope, $useNativeTypes); + } + /** * Whether the given scope, not this result, owns the answer to "what is * this expression here": narrowable expressions the scope knows (variables diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 015b38c8970..a3d6c02d36f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1688,11 +1688,17 @@ public function processStmtNode( $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope; // $originalScope may narrow the iteratee to a non-empty array - a genuinely - // different scope than its own - so reprocess it there rather than re-running - // its result on a foreign scope. - $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, new ExpressionResultStorage()); - $foreachIterateeType = $iterateeResult->getType(); - $foreachNativeIterateeType = $iterateeResult->getNativeType(); + // different scope than its own. The narrowing is tracked by the scope + // (getTypeOnScope's authoritative read), so the iteratee only needs + // reprocessing there when the scope neither owns nor matches its state. + if ($condResult->answersOnScope($originalScope, false) && $condResult->answersOnScope($originalScope, true)) { + $foreachIterateeType = $condResult->getTypeOnScope($originalScope, false); + $foreachNativeIterateeType = $condResult->getTypeOnScope($originalScope, true); + } else { + $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, new ExpressionResultStorage()); + $foreachIterateeType = $iterateeResult->getType(); + $foreachNativeIterateeType = $iterateeResult->getNativeType(); + } $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context, $foreachIterateeType, $foreachNativeIterateeType); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; @@ -1776,10 +1782,17 @@ public function processStmtNode( } // $scope is the post-loop scope; the body may have modified the iteratee - // (e.g. $arr[] = ...), a genuinely different scope than the iteratee's own, - // so reprocess it there to observe the modified type. - $iterateeResult = $this->processExprOnDemand($stmt->expr, $scope, new ExpressionResultStorage()); - $exprType = $iterateeResult->getType(); + // (e.g. $arr[] = ...). A tracked iteratee reads the modified type off the + // scope (getTypeOnScope's authoritative read); only an untracked one whose + // inputs the body changed needs reprocessing there to observe it. + if ($condResult->answersOnScope($scope, false) && $condResult->answersOnScope($scope, true)) { + $exprType = $condResult->getTypeOnScope($scope, false); + $exprNativeType = $condResult->getTypeOnScope($scope, true); + } else { + $postLoopIterateeResult = $this->processExprOnDemand($stmt->expr, $scope, new ExpressionResultStorage()); + $exprType = $postLoopIterateeResult->getType(); + $exprNativeType = $postLoopIterateeResult->getNativeType(); + } $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1842,7 +1855,7 @@ public function processStmtNode( $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); $keyTypeChanged = false; $keyLoopType = $exprType->getIterableKeyType(); - $keyLoopNativeType = $iterateeResult->getNativeType()->getIterableKeyType(); + $keyLoopNativeType = $exprNativeType->getIterableKeyType(); if ($keyVarExpr !== null) { $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); @@ -1858,7 +1871,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $iterateeResult->getNativeType(); + $nativeExprType = $exprNativeType; $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -3186,6 +3199,7 @@ private function processExprNodeInternal( ): ExpressionResult { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { $newExpr = new FunctionCallableNode($expr->name, $expr); From 6a85ecb71658b81336a209ff807e49759707e588 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:22:56 +0200 Subject: [PATCH 355/378] Compose the foreach non-empty-iteratee narrowing from the iteratee's result Every foreach processing narrowed the body scope by a synthetic NotIdentical($iteratee, []) walked through the on-demand dispatcher - at three sites, one of them inside the convergence loop, so the iteratee and the sentinel were re-priced up to LOOP_SCOPE_ITERATIONS + 2 times per processing. The walked synthetic delegates to IdenticalNarrowingHelper::specifyIdenticalAgainstType anyway; call it directly with the iteratee's result and the empty-array sentinel, compute the narrowed scope once, and reuse it at all three merge points. The walk remains as the composition's miss seam (FuncCall families, dynamic class-constant subjects). --- src/Analyser/NodeScopeResolver.php | 43 +++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a3d6c02d36f..db3b95d1bf4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -52,6 +52,7 @@ use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; @@ -146,7 +147,10 @@ use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\BooleanType; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\ErrorType; @@ -1680,13 +1684,46 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); } + // the "iteratee !== []" narrowing every loop pass merges in - composed + // once from the iteratee's result (the same sentinel comparison the + // walked synthetic would delegate to); the walk is the composition's + // miss seam + $nonEmptyIterateeScope = $scope; + if ($this->polluteScopeWithAlwaysIterableForeach) { + $identicalNarrowingHelper = $this->container->getByType(IdenticalNarrowingHelper::class); + $emptyArrayType = new ConstantArrayType([], []); + $nonEmptyTypes = $identicalNarrowingHelper->specifyIdenticalAgainstType( + $stmt->expr, + $condResult, + $arrayComparisonExpr->right, + $emptyArrayType, + TypeSpecifierContext::createFalse(), + $scope, + $identicalNarrowingHelper->captureFirstArgResult($stmt->expr, $storage), + static function () use ($condResult, $emptyArrayType): Type { + $iterateeType = $condResult->getType(); + if ($iterateeType->equals($emptyArrayType)) { + return new ConstantBooleanType(true); + } + if ($emptyArrayType->isSuperTypeOf($iterateeType)->no()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }, + ); + $nonEmptyIterateeScope = $nonEmptyTypes !== null + ? $scope->applySpecifiedTypes($nonEmptyTypes) + : $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()); + } + $originalStorage = $storage; $unrolledEndScope = null; $unrolledTotalKeys = null; if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope; + $originalScope = $nonEmptyIterateeScope; // $originalScope may narrow the iteratee to a non-empty array - a genuinely // different scope than its own. The narrowing is tracked by the scope // (getTypeOnScope's authoritative read), so the iteratee only needs @@ -1709,7 +1746,7 @@ public function processStmtNode( $count = 0; do { $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage->duplicate(); $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); @@ -1729,7 +1766,7 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; From 02280c3a5e9649045ab57b0e8ef02fcf7401a81c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:29:55 +0200 Subject: [PATCH 356/378] Compose switch case narrowing and read match/switch subjects off the scope The switch handling narrowed each case's branch scope by walking a synthetic Equal(subject, case) through the on-demand dispatcher; the walk delegates to IdenticalNarrowingHelper::specifyEqual anyway, so call it directly with the subject's and the case's results (the walk stays as the composition's miss seam). The match and switch exhaustiveness checks re-walked their subject on the "no arm/case matched" scope; that narrowing is tracked by the scope, so getTypeOnScope's authoritative read answers without a walk, gated by answersOnScope like the foreach iteratee. --- src/Analyser/ExprHandler/MatchHandler.php | 9 ++++--- src/Analyser/NodeScopeResolver.php | 29 +++++++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 49bf4f0b2cc..6fe2bcc72fb 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -449,9 +449,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond; if (!$isExhaustive) { - // $matchScope is the subject narrowed by "no arm matched" - a genuinely - // different scope than the subject's own - so reprocess the subject there. - $remainingType = $nodeScopeResolver->processExprOnDemand($expr->cond, $matchScope, new ExpressionResultStorage())->getType(); + // $matchScope is the subject narrowed by "no arm matched". The arm + // narrowing is tracked by the scope (getTypeOnScope's authoritative + // read); only an untracked subject needs reprocessing there. + $remainingType = $condResult->answersOnScope($matchScope, false) + ? $condResult->getTypeOnScope($matchScope, false) + : $nodeScopeResolver->processExprOnDemand($expr->cond, $matchScope, new ExpressionResultStorage())->getType(); if ($remainingType instanceof NeverType) { $isExhaustive = true; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index db3b95d1bf4..9e36aa03f64 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2338,7 +2338,23 @@ static function () use ($condResult, $emptyArrayType): Type { $hasYield = $hasYield || $caseResult->hasYield(); $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints()); - $branchScope = $this->narrowScopeWithCondition($caseResult->getScope(), $condExpr, TypeSpecifierContext::createTruthy()); + // the == narrowing composed from the subject's and the case's + // results (what the walked synthetic delegates to); the walk is + // the composition's miss seam + $caseEqualTypes = $this->container->getByType(IdenticalNarrowingHelper::class)->specifyEqual( + $this, + $stmt->cond, + $caseNode->cond, + $condResult, + $caseResult, + TypeSpecifierContext::createTruthy(), + $caseResult->getScope(), + null, + null, + ); + $branchScope = $caseEqualTypes !== null + ? $caseResult->getScope()->applySpecifiedTypes($caseEqualTypes->setRootExpr($condExpr)) + : $this->narrowScopeWithCondition($caseResult->getScope(), $condExpr, TypeSpecifierContext::createTruthy()); } else { $hasDefaultCase = true; $fullCondExpr = null; @@ -2375,10 +2391,13 @@ static function () use ($condResult, $emptyArrayType): Type { } } - // $scopeForBranches is the subject narrowed by "none of the cases matched"; - // that is a genuinely different scope than the subject's own, so reprocess - // the subject there rather than re-running its result on a foreign scope. - $exhaustive = $this->processExprOnDemand($stmt->cond, $scopeForBranches, new ExpressionResultStorage())->getType() instanceof NeverType; + // $scopeForBranches is the subject narrowed by "none of the cases + // matched". The narrowing is tracked by the scope (getTypeOnScope's + // authoritative read); only an untracked subject needs reprocessing there. + $remainingCaseType = $condResult->answersOnScope($scopeForBranches, false) + ? $condResult->getTypeOnScope($scopeForBranches, false) + : $this->processExprOnDemand($stmt->cond, $scopeForBranches, new ExpressionResultStorage())->getType(); + $exhaustive = $remainingCaseType instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; From abf31f620dffa173e94c82b38a00128b50ad94e1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:33:00 +0200 Subject: [PATCH 357/378] Read the coalesce left side off the left-is-set scope instead of re-walking CoalesceCompositionHelper's left-is-set branch re-walked the left expression on the isset-narrowed scope for every ?? / ??= type ask. The chain narrowing is tracked by the scope (getTypeOnScope's authoritative read), so the walk only remains for an untracked left side, gated by answersOnScope. --- .../Helper/CoalesceCompositionHelper.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php index 4189d4d9f74..9b84d94eac8 100644 --- a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php +++ b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php @@ -75,10 +75,11 @@ public function composeType( return !$isNull->yes(); }); - // the left side's type when it is set: the left re-processed on the - // left-is-set narrowed scope (a genuinely different scope than the - // left's own - offsets resolve against the HasOffset-narrowed parent) - $leftIsSetType = function () use ($leftExpr, $nodeScopeResolver, $evaluationScope, $chainResults, $rootExpr): Type { + // the left side's type when it is set: the left read on the left-is-set + // narrowed scope (offsets resolve against the HasOffset-narrowed parent). + // The narrowing is tracked by the scope (getTypeOnScope's authoritative + // read); only an untracked left side needs reprocessing there. + $leftIsSetType = function () use ($leftExpr, $leftResult, $nodeScopeResolver, $evaluationScope, $chainResults, $rootExpr): Type { $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( $evaluationScope, $leftExpr, @@ -86,8 +87,12 @@ public function composeType( $rootExpr, TypeSpecifierContext::createTruthy(), ); + $leftIsSetScope = $evaluationScope->applySpecifiedTypes($leftIssetTypes); + $leftType = $leftResult->answersOnScope($leftIsSetScope, false) + ? $leftResult->getTypeOnScope($leftIsSetScope, false) + : $nodeScopeResolver->processExprOnDemand($leftExpr, $leftIsSetScope, new ExpressionResultStorage())->getType(); - return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($leftExpr, $evaluationScope->applySpecifiedTypes($leftIssetTypes), new ExpressionResultStorage())->getType()); + return TypeCombinator::removeNull($leftType); }; if ($result !== null && $result !== false) { From 8d857cbd9ec7e109ee8cb408b00b078c5cfd17ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 20:36:10 +0200 Subject: [PATCH 358/378] Read the short-ternary condition off its truthy scope instead of re-walking Same pattern as the foreach iteratee and match/switch subjects: the truthy narrowing is tracked by the scope, so getTypeOnScope's authoritative read answers without a walk; answersOnScope gates the untracked remainder onto the reprocessing path. --- src/Analyser/ExprHandler/TernaryHandler.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 2068403b000..1f6345c3a19 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -150,9 +150,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $booleanConditionType = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); $elseType = $elseResult->getTypeOnScope($elseProcessingScope, $nativeTypesPromoted); if ($expr->if === null || $ifResult === null) { - // short-ternary truthy value: the condition read on its own truthy scope - // is a different scope than its own, so reprocess it there. - $condTruthyType = $nodeScopeResolver->processExprOnDemand($expr->cond, $ifProcessingScope, new ExpressionResultStorage())->getType(); + // short-ternary truthy value: the condition read on its own truthy + // scope. The truthy narrowing is tracked by the scope + // (getTypeOnScope's authoritative read); only an untracked + // condition needs reprocessing there. + $condTruthyType = $ternaryCondResult->answersOnScope($ifProcessingScope, false) + ? $ternaryCondResult->getTypeOnScope($ifProcessingScope, false) + : $nodeScopeResolver->processExprOnDemand($expr->cond, $ifProcessingScope, new ExpressionResultStorage())->getType(); if ($booleanConditionType->isTrue()->yes()) { return $condTruthyType; } From c6055374f6a91c939cf120223a6ea609cc16cddf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 21:17:23 +0200 Subject: [PATCH 359/378] Memoize flush-priced results for repeated rule asks Rules ask about the same (usually synthetic) expression repeatedly across statement boundaries; each parked fiber was resolved by a fresh on-demand walk on a duplicated, discarded storage, so every subsequent ask re-walked the node and its whole subtree. The channel-aggregated census put this at ~31% of all corpus reprocessings (357k flush walks, 217k of them re-asks of an already-priced expression, 99.9% answerable from the last result). The last flush result is now kept per expression (WeakMap - entries die with the AST) and reused when nothing the expression reads changed since the walk, on both flavours; the resumed fiber reads the result's own walk-position type, so scope-authoritative answers deliberately do not qualify. State mismatches re-walk as before. --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index c1497565ebc..1515698d275 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; +use WeakMap; use function array_pop; use function count; use function get_class; @@ -24,6 +25,13 @@ final class FiberNodeScopeResolver extends NodeScopeResolver { + /** + * Last flush-priced result per asked expression - see processPendingFibers(). + * + * @var WeakMap|null + */ + private ?WeakMap $flushedOnDemandResults = null; + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -149,13 +157,31 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void $fiber = $pending['fiber']; - // Process the synthetic node with a duplicated storage so that the result - // computed from the asker's scope does not poison the real storage. - $expressionResult = $this->processExprOnDemand( - $request->expr, - $request->scope->toMutatingScope(), - $storage->duplicate(), - ); + // Rules ask about the same (usually synthetic) node repeatedly across + // statement boundaries; the priced result is reusable whenever nothing + // the expression reads changed since the walk. Without the memo every + // ask re-walked the node and its subtree - the single largest + // reprocessing channel in the corpus census (~31% of walks). + $askScope = $request->scope->toMutatingScope(); + $this->flushedOnDemandResults ??= new WeakMap(); + // the resumed fiber reads the result's own (walk-position) type, so the + // memo only answers when nothing the expression reads changed - a + // scope-authoritative answer would live in the scope, not the result + $expressionResult = $this->flushedOnDemandResults[$request->expr] ?? null; + if ( + $expressionResult === null + || !$expressionResult->askScopeVariableStateMatches($askScope, false) + || !$expressionResult->askScopeVariableStateMatches($askScope, true) + ) { + // Process the node with a duplicated storage so that the result + // computed from the asker's scope does not poison the real storage. + $expressionResult = $this->processExprOnDemand( + $request->expr, + $askScope, + $storage->duplicate(), + ); + $this->flushedOnDemandResults[$request->expr] = $expressionResult; + } $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); From 80a35f8960b3c2785e3927075650bca66d94a36f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 7 Jul 2026 22:07:23 +0200 Subject: [PATCH 360/378] Revert "Memoize flush-priced results for repeated rule asks" The memo's CPU win is real (-23% self-analysis user CPU, ahead of 2.2.x by ~15%) but retaining the flush-walk results pins their scope and callback graphs: +1.3GB, which nondeterministically breaks make phpstan's 450M per-worker limit. Every slim variant measured (eager types, native re-walk, downgrade generations) loses the CPU win - the lazily memoized native flavour on the retained result is what answers repeated asks for free. Re-land after ExpressionResult retention shrinks (callback-scope-removal) or with a cheaper native strategy. --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 40 ++++--------------- 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 1515698d275..c1497565ebc 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -13,7 +13,6 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; -use WeakMap; use function array_pop; use function count; use function get_class; @@ -25,13 +24,6 @@ final class FiberNodeScopeResolver extends NodeScopeResolver { - /** - * Last flush-priced result per asked expression - see processPendingFibers(). - * - * @var WeakMap|null - */ - private ?WeakMap $flushedOnDemandResults = null; - /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -157,31 +149,13 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void $fiber = $pending['fiber']; - // Rules ask about the same (usually synthetic) node repeatedly across - // statement boundaries; the priced result is reusable whenever nothing - // the expression reads changed since the walk. Without the memo every - // ask re-walked the node and its subtree - the single largest - // reprocessing channel in the corpus census (~31% of walks). - $askScope = $request->scope->toMutatingScope(); - $this->flushedOnDemandResults ??= new WeakMap(); - // the resumed fiber reads the result's own (walk-position) type, so the - // memo only answers when nothing the expression reads changed - a - // scope-authoritative answer would live in the scope, not the result - $expressionResult = $this->flushedOnDemandResults[$request->expr] ?? null; - if ( - $expressionResult === null - || !$expressionResult->askScopeVariableStateMatches($askScope, false) - || !$expressionResult->askScopeVariableStateMatches($askScope, true) - ) { - // Process the node with a duplicated storage so that the result - // computed from the asker's scope does not poison the real storage. - $expressionResult = $this->processExprOnDemand( - $request->expr, - $askScope, - $storage->duplicate(), - ); - $this->flushedOnDemandResults[$request->expr] = $expressionResult; - } + // Process the synthetic node with a duplicated storage so that the result + // computed from the asker's scope does not poison the real storage. + $expressionResult = $this->processExprOnDemand( + $request->expr, + $request->scope->toMutatingScope(), + $storage->duplicate(), + ); $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); From 12b204329e440a723cf099873259c966a8ddc094 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 09:36:09 +0200 Subject: [PATCH 361/378] Build closure/arrow call-arg types from the single body walk A closure/arrow function passed as a call argument paid up to four full body walks: the faithful-return gather walk, the real walk, a second walk on the promoted scope for the native flavour of the stored result, and (arrows) an on-demand walk when the invalidate-expressions read asked Scope::getType() before the result was stored. - The native flavour is now built from the same gathered returns/yields, reading the stored native types off the walk's scopes (buildClosureTypeForClosure(native: true), mirroring what buildClosureTypeForArrowFunction() and the plain handlers already do). - The builders now compute the closure-type cache key on the flavour-correct scope and callable parameters: the native build no longer clobbers the phpdoc cache slot (previously buildClosureTypeForArrowFunction(native: true) cached the native return type under the phpdoc key), and a later getClosureType() ask on the promoted scope answers from the build instead of re-walking. - The arrow branch builds the ClosureType once, stores the result first, and reads the invalidate expressions off the built type. - The gather walk only runs when acceptor selection is type-driven (multiple variants, named-argument variants, or templates/conditionals to resolve); otherwise the signature-only shallow type keeps the count/name bookkeeping correct without a body walk. Co-Authored-By: Claude Fable 5 --- .../Helper/ClosureTypeResolver.php | 51 ++++++++++++++---- src/Analyser/NodeScopeResolver.php | 54 +++++++++++++++---- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index 2b3c0176c24..a3c2f6b9bb6 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -275,13 +275,14 @@ public function buildClosureTypeForClosure( array $throwPoints, array $impurePoints, array $invalidateExpressions, + bool $native = false, ): ClosureType { if ($this->bodyWalkHasOwnParameterTypes($expr)) { - return $this->getClosureType($scope, $expr); + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); } - [$parameters, $isVariadic] = $this->buildParametersAndAcceptors($scope, $expr); + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); return $this->buildClosureTypeFromClosureWalk( $scope, @@ -294,6 +295,16 @@ public function buildClosureTypeForClosure( array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $throwPoints), $impurePoints, $invalidateExpressions, + // the flavour-correct key both keeps the native build from clobbering + // the phpdoc cache slot and lets a later getClosureType() ask on the + // promoted scope answer from this build instead of re-walking + $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + ), + $native, ); } @@ -321,11 +332,19 @@ public function buildClosureTypeForArrowFunction( return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); } - [$parameters, $isVariadic, $callableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); - return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters)); + // the flavour-correct key both keeps the native build from clobbering the + // phpdoc cache slot and lets a later getClosureType() ask on the promoted + // scope answer from this build instead of re-walking + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + )); } /** @@ -453,10 +472,14 @@ private function buildClosureTypeFromClosureWalk( array $impurePoints, array $invalidateExpressions, ?string $cacheKey = null, + bool $native = false, ): ClosureType { $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); + // like resolveArrowFunctionReturnType(): the single walk stored both + // flavours on the gathered scopes, so the native flavour just reads the + // stored native types - no second body walk on the promoted scope $returnTypes = []; $hasNull = false; foreach ($returnStatements as [$returnNode, $returnScope]) { @@ -465,7 +488,11 @@ private function buildClosureTypeFromClosureWalk( continue; } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + $readScope = $returnScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } + $returnTypes[] = $readScope->getType($returnNode->expr); } if (count($returnTypes) === 0) { @@ -488,25 +515,29 @@ private function buildClosureTypeFromClosureWalk( $keyTypes = []; $valueTypes = []; foreach ($yieldStatements as [$yieldNode, $yieldScope]) { + $readScope = $yieldScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } if ($yieldNode instanceof Yield_) { if ($yieldNode->key === null) { $keyTypes[] = new IntegerType(); } else { - $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); + $keyTypes[] = $readScope->getType($yieldNode->key); } if ($yieldNode->value === null) { $valueTypes[] = new NullType(); } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); + $valueTypes[] = $readScope->getType($yieldNode->value); } continue; } - $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); - $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); - $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyTypes[] = $readScope->getIterableKeyType($yieldFromType); + $valueTypes[] = $readScope->getIterableValueType($yieldFromType); } $returnType = new GenericObjectType(Generator::class, [ diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 9e36aa03f64..792bf24e0b2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4225,6 +4225,16 @@ public function processArgs( // comes from the post-loop resolved acceptor. $metadataAcceptor = $parametersAcceptors[0] ?? null; + // Whether selecting an acceptor is type-driven at all: multiple variants to + // choose between, templates or conditionals to resolve from the arg types, + // or named-argument variants. When it is not, the gathered arg types can + // never influence the selected acceptor, so the faithful-return gather walk + // of a closure/arrow argument (gatherClosureArgType()) would be pure waste - + // its signature-only shallow type keeps the count/name bookkeeping correct. + $typeDrivenAcceptorSelection = count($parametersAcceptors) > 1 + || $namedArgumentsVariants !== null + || ($metadataAcceptor !== null && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($metadataAcceptor)); + $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -4275,7 +4285,9 @@ public function processArgs( // contribution (a TValue from its return) participates in the final // resolution (see gatherClosureArgType()). $originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; - $gatheredArgTypeByIndex[$i] = $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope); + $gatheredArgTypeByIndex[$i] = $typeDrivenAcceptorSelection + ? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope) + : $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $arg->value, shallow: true); $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]); } @@ -4445,7 +4457,19 @@ public function processArgs( $closureResult->getClosureTypeImpurePoints(), $closureResult->getInvalidateExpressions(), ), - nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + // the native flavour reads the stored native types off the same + // single body walk - no second walk on the promoted scope + nativeType: $closureTypeResolver->buildClosureTypeForClosure( + $scopeToPass, + $arg->value, + $closureResult->getGatheredReturnStatements(), + $closureResult->getGatheredYieldStatements(), + $closureResult->getExecutionEnds(), + $closureResult->getThrowPoints(), + $closureResult->getClosureTypeImpurePoints(), + $closureResult->getInvalidateExpressions(), + native: true, + ), typeCallback: null, specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); @@ -4541,14 +4565,20 @@ public function processArgs( $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $arrowFunctionType = $scope->getType($arg->value); - if ($arrowFunctionType instanceof ClosureType) { - $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; - } - } $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); $arrowFunctionScope = $arrowFunctionResult->getArrowFunctionScope(); + // both flavours are built from the single body walk (see + // ArrowFunctionHandler); the built type also answers the + // invalidate-expressions read below without re-walking the + // still-unstored node through Scope::getType() + $arrowFunctionType = $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( $arrowFunctionExprResult->getScope(), beforeScope: $scopeToPass, @@ -4557,18 +4587,22 @@ public function processArgs( isAlwaysTerminating: $arrowFunctionExprResult->isAlwaysTerminating(), throwPoints: $arrowFunctionExprResult->getThrowPoints(), impurePoints: $arrowFunctionExprResult->getImpurePoints(), - type: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + type: $arrowFunctionType, + nativeType: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( $scopeToPass, $arg->value, $arrowFunctionScope, $arrowFunctionResult->getClosureTypeThrowPoints(), $arrowFunctionResult->getClosureTypeImpurePoints(), $arrowFunctionResult->getInvalidateExpressions(), + native: true, ), - nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), typeCallback: null, specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); + if ($this->shouldInvalidateCallbackExpressions($parameter)) { + $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; + } } } else { $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; From 712a3c158792549641d779fe2eedddd75503405c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 10:01:11 +0200 Subject: [PATCH 362/378] Select the per-argument metadata acceptor only where its resolution is consumed For a multi-variant or templated callee, every argument re-ran the full type-driven acceptor selection (intrinsic overrides + template inference over all args padded to the full count) - O(args^2) with a full generic resolution per argument. Only a closure/arrow function consumes the generic-RESOLVED parameter type: its parameters and body scope are typed from the resolved callable(T), directly or through the in-function-call stack when nested inside the argument. Every other argument reads variant-stable facts off its parameter (by-ref flag, callable bookkeeping), so a single all-mixed count-stable selection now serves all of them; the per-argument gathered-so-far selection remains for arguments containing a closure/arrow function. Cuts acceptor selections by ~73% on a templated-call corpus with byte-identical output. Co-Authored-By: Claude Fable 5 --- src/Analyser/NodeScopeResolver.php | 77 +++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 792bf24e0b2..3765e8613f2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4274,6 +4274,7 @@ public function processArgs( }); $argResults = []; + $countStableMetadataAcceptor = null; foreach ($processingOrder as $i) { $arg = $args[$i]; @@ -4296,19 +4297,40 @@ public function processArgs( $metadataAcceptor !== null && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) ) { - // Resolve the acceptor for this argument from the args gathered SO FAR, padded to the - // full argument count with mixed. Closures sort last and by-ref out-params follow the - // args that pin them, so determining siblings are already processed; the mixed pad keeps - // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), - // while processed siblings resolve a generic callable(T) parameter. No forward read. - $paddedTypes = []; - $paddedUnpack = false; - $paddedHasName = false; - foreach ($args as $j => $paddedArg) { - $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; - $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); - } - $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); + if ($this->argConsumesResolvedParameterType($arg->value)) { + // Resolve the acceptor for this argument from the args gathered SO FAR, padded to the + // full argument count with mixed. Closures sort last and by-ref out-params follow the + // args that pin them, so determining siblings are already processed; the mixed pad keeps + // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), + // while processed siblings resolve a generic callable(T) parameter. No forward read. + $paddedTypes = []; + $paddedUnpack = false; + $paddedHasName = false; + foreach ($args as $j => $paddedArg) { + $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); + } + $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); + } else { + // Only a closure/arrow function consumes the generic-RESOLVED + // parameter type: its body is inferred from the resolved + // callable(T) - directly, or through the in-function-call stack + // when nested anywhere inside the argument. Every other argument + // reads variant-stable facts off its parameter (by-ref flag, + // callable bookkeeping), so one all-mixed count-stable selection + // serves them all instead of a full template inference per argument. + if ($countStableMetadataAcceptor === null) { + $paddedTypes = []; + $paddedUnpack = false; + $paddedHasName = false; + foreach ($args as $j => $paddedArg) { + $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, new MixedType()); + } + $countStableMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); + } + $argMetadataAcceptor = $countStableMetadataAcceptor; + } } $parameters = $argMetadataAcceptor?->getParameters(); @@ -4825,6 +4847,35 @@ private function addGatheredArgType(array &$types, bool &$unpack, bool &$hasName } } + /** + * Whether processing this argument consumes the generic-RESOLVED parameter + * type: a closure/arrow function does - its parameters and body scope are + * typed from the resolved callable(T) - whether it IS the argument or is + * nested anywhere inside it (the enclosing parameter is pushed on the + * in-function-call stack and the nested closure types itself from there). + * Every other argument only reads variant-stable facts off its parameter. + */ + private function argConsumesResolvedParameterType(Expr $value): bool + { + if ($value instanceof Expr\Closure || $value instanceof Expr\ArrowFunction) { + return true; + } + + // cached on the node - args are re-processed across convergence passes + $cached = $value->getAttribute('phpstanArgContainsClosure'); + if ($cached !== null) { + return $cached; + } + + $contains = (new NodeFinder())->findFirst( + [$value], + static fn (Node $node): bool => $node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction, + ) !== null; + $value->setAttribute('phpstanArgContainsClosure', $contains); + + return $contains; + } + /** * Resolves the type of a closure/arrow function argument for the generic * gather, mirroring ParametersAcceptorSelector::selectFromArgs(): the closure From 430cb05ab8e6be578ba1dfdf5c7b696430ed299c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 10:29:58 +0200 Subject: [PATCH 363/378] Make pass-local storages scope-visible and consume loop condition results Loop-convergence passes and expression-level loop processing wrote into throwaway storage duplicates that never reached the scope's storage stack, so every in-pass ask (applySpecifiedTypes pricing, stored-result reads) missed the pass's own results and re-priced real nodes on demand each iteration. - The conditional storage push moves from processStmtNodesInternal into processStmtNodesInternalWithoutFlushingPendingFibers, covering the closure by-ref convergence passes that call it directly. - While/Do/For condition walks, For loop-expression walks, and foreach enterForeach virtual assigns now push their pass storage for the pass's duration (branch-scope derivations forced inside the region). - The While post-loop falsey narrowing and the foreach iteratee re-pricing walk on a duplicate of the live storage instead of a fresh one, so subresults whose state did not change answer from the stored results. - Do/For read the always-iterates check off the condition's processed result instead of a scope-based read that was a guaranteed storage miss (the condition was only ever stored into discarded convergence duplicates) followed by processing the condition a second time. Cuts on-demand walks by 31% on a loop-heavy corpus and 15% on TypeCombinator.php self-analysis with byte-identical output. Co-Authored-By: Claude Fable 5 --- src/Analyser/NodeScopeResolver.php | 261 ++++++++++++++++++----------- 1 file changed, 164 insertions(+), 97 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3765e8613f2..3ec59a21115 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -652,29 +652,14 @@ private function processStmtNodesInternal( StatementContext $context, ): InternalStatementResult { - // make the storage this walk writes into scope-visible: loop-convergence - // passes thread a throwaway duplicate that would otherwise never reach - // the storage stack, so every in-pass ask (applySpecifiedTypes pricing, - // rules via Scope::getType) would miss the pass's own results and - // re-process real nodes on demand - $pushStorage = $scope->getCurrentExpressionResultStorage() !== $storage; - if ($pushStorage) { - $scope->pushExpressionResultStorage($storage); - } - try { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - ); - } finally { - if ($pushStorage) { - $scope->popExpressionResultStorage(); - } - } + $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( + $parentNode, + $stmts, + $scope, + $storage, + $nodeCallback, + $context, + ); // Flush pending fibers only at a scope boundary - a function/method body, // a class/trait body, a namespace. Nested control-flow statement lists // (if/else branches, loop and switch/try bodies) must NOT flush: a rule @@ -707,6 +692,38 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( callable $nodeCallback, StatementContext $context, ): InternalStatementResult + { + // make the storage this walk writes into scope-visible: loop-convergence + // passes (including the closure by-ref convergence, which calls this + // method directly) thread a throwaway duplicate that would otherwise + // never reach the storage stack, so every in-pass ask + // (applySpecifiedTypes pricing, rules via Scope::getType) would miss the + // pass's own results and re-process real nodes on demand + $pushStorage = $scope->getCurrentExpressionResultStorage() !== $storage; + if ($pushStorage) { + $scope->pushExpressionResultStorage($storage); + } + try { + return $this->doProcessStmtNodes($parentNode, $stmts, $scope, $storage, $nodeCallback, $context); + } finally { + if ($pushStorage) { + $scope->popExpressionResultStorage(); + } + } + } + + /** + * @param Node\Stmt[] $stmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function doProcessStmtNodes( + Node $parentNode, + array $stmts, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + StatementContext $context, + ): InternalStatementResult { $exitPoints = []; $throwPoints = []; @@ -1732,7 +1749,9 @@ static function () use ($condResult, $emptyArrayType): Type { $foreachIterateeType = $condResult->getTypeOnScope($originalScope, false); $foreachNativeIterateeType = $condResult->getTypeOnScope($originalScope, true); } else { - $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, new ExpressionResultStorage()); + // the duplicate lets subresults whose state matches answer from + // the already-processed iteratee instead of being re-priced + $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, $originalStorage->duplicate()); $foreachIterateeType = $iterateeResult->getType(); $foreachNativeIterateeType = $iterateeResult->getNativeType(); } @@ -1742,17 +1761,27 @@ static function () use ($condResult, $emptyArrayType): Type { $unrolledEndScope = $unrolledResult['endScope']; $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + } finally { + $scope->popExpressionResultStorage(); + } $count = 0; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { break; @@ -1975,24 +2004,32 @@ static function () use ($condResult, $emptyArrayType): Type { } elseif ($stmt instanceof While_) { $originalStorage = $storage; $storage = $originalStorage->duplicate(); - $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); - $condScope = $condResult->getFalseyScope(); - if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { - if (!$this->polluteScopeWithLoopInitialAssignments) { - $scope = $condScope->mergeWith($scope); - } + // pass-local storages are pushed for the duration of each pass so + // in-pass asks (applySpecifiedTypes pricing, branch-scope derivation) + // read the pass's own results instead of re-pricing on demand + $scope->pushExpressionResultStorage($storage); + try { + $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $condScope = $condResult->getFalseyScope(); + if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { + if (!$this->polluteScopeWithLoopInitialAssignments) { + $scope = $condScope->mergeWith($scope); + } - return new InternalStatementResult( - $scope, - $condResult->hasYield(), - false, - [], - $condResult->getThrowPoints(), - $condResult->getImpurePoints(), - ); + return new InternalStatementResult( + $scope, + $condResult->hasYield(), + false, + [], + $condResult->getThrowPoints(), + $condResult->getImpurePoints(), + ); + } + $bodyScope = $condResult->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } - $bodyScope = $condResult->getTruthyScope(); if ($context->isTopLevel()) { $count = 0; @@ -2000,11 +2037,16 @@ static function () use ($condResult, $emptyArrayType): Type { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { break; @@ -2027,7 +2069,9 @@ static function () use ($condResult, $emptyArrayType): Type { // the loop condition narrows the post-loop scope to its falsey branch; // $finalScope (after the body ran) is a different scope than the condition's // own, so reprocess the condition there rather than re-running its result. - $finalScope = $finalScope->applySpecifiedTypes($this->processExprOnDemand($stmt->cond, $finalScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey())); + // The duplicate lets subresults whose state did not change in the body + // answer from the final pass instead of being re-priced. + $finalScope = $finalScope->applySpecifiedTypes($this->processExprOnDemand($stmt->cond, $finalScope, $storage->duplicate())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey())); $alwaysIterates = false; $neverIterates = false; @@ -2097,17 +2141,22 @@ static function () use ($condResult, $emptyArrayType): Type { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); $storage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); if ($bodyScope->equals($prevScope)) { break; } @@ -2128,9 +2177,16 @@ static function () use ($condResult, $emptyArrayType): Type { $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + // the condition is processed once on the post-body scope; its result + // answers both the always-iterates check below and the falsey post-loop + // scope - the previous scope-based read here was a guaranteed storage + // miss (the condition was only ever stored into discarded convergence + // duplicates) that re-priced the condition on demand before this walk + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readTypeOfMaybeStored($stmt->cond, $bodyScope) : $this->readTypeOfMaybeStored($stmt->cond, $bodyScope->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -2146,13 +2202,10 @@ static function () use ($condResult, $emptyArrayType): Type { $finalScope = $scope; } if (!$alwaysTerminating) { - $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); $finalScope = $condResult->getFalseyScope(); - } else { - $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); } $breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class); @@ -2192,23 +2245,27 @@ static function () use ($condResult, $emptyArrayType): Type { $lastCondExpr = array_last($stmt->cond) ?? null; if (count($stmt->cond) > 0) { $storage = $originalStorage->duplicate(); + $scope->pushExpressionResultStorage($storage); + try { + foreach ($stmt->cond as $condExpr) { + $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $initScope = $condResult->getScope(); + $condResultScope = $condResult->getScope(); + + // only the last condition expression is relevant whether the loop continues + // see https://www.php.net/manual/en/control-structures.for.php + if ($condExpr === $lastCondExpr) { + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); + } - foreach ($stmt->cond as $condExpr) { - $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $initScope = $condResult->getScope(); - $condResultScope = $condResult->getScope(); - - // only the last condition expression is relevant whether the loop continues - // see https://www.php.net/manual/en/control-structures.for.php - if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); - $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); + $hasYield = $hasYield || $condResult->hasYield(); + $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); + $bodyScope = $condResult->getTruthyScope(); } - - $hasYield = $hasYield || $condResult->hasYield(); - $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); - $bodyScope = $condResult->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } } @@ -2218,21 +2275,26 @@ static function () use ($condResult, $emptyArrayType): Type { $prevScope = $bodyScope; $storage = $originalStorage->duplicate(); $bodyScope = $bodyScope->mergeWith($initScope); - if ($lastCondExpr !== null) { - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - } - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + $scope->pushExpressionResultStorage($storage); + try { + if ($lastCondExpr !== null) { + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } - foreach ($stmt->loop as $loopExpr) { - $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); - $bodyScope = $exprResult->getScope(); - $hasYield = $hasYield || $exprResult->hasYield(); - $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + foreach ($stmt->loop as $loopExpr) { + $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { @@ -2251,8 +2313,13 @@ static function () use ($condResult, $emptyArrayType): Type { $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($this->readTypeOfMaybeStored($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + // process the condition once and read the always-iterates check off + // its result - the previous scope-based read was a guaranteed + // storage miss (the condition was only stored into discarded + // convergence duplicates) that re-priced it on demand + $condResult = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = $alwaysIterates->and($condResult->getType()->toBoolean()->isTrue()); + $bodyScope = $condResult->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope, $storage); } From 6dd28420c9a79fa6a4c788856d12871fece85101 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 13:27:28 +0200 Subject: [PATCH 364/378] Memoize the generator check per function-like node isGenerator() recursively scanned the whole function body on every call, and reflections for the same node are recreated per ask - the yield scan ran 1.3M times on the src/Analyser+src/Rules corpus. The answer is a property of the AST node, so it is memoized on the node itself (WeakMap). Self-analysis user CPU: -5%. --- .../PhpFunctionFromParserNodeReflection.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index 0c94a5082c7..aaebd12d095 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -22,6 +22,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; +use WeakMap; use function array_map; use function array_reverse; use function is_string; @@ -33,6 +34,14 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, ExtendedParametersAcceptor { + /** + * The yield scan walks the whole body; reflections for the same node are + * recreated per ask, so the answer is memoized on the AST node itself. + * + * @var WeakMap|null + */ + private static ?WeakMap $generatorCache = null; + /** @var Function_|ClassMethod|Node\PropertyHook */ private Node\FunctionLike $functionLike; @@ -277,7 +286,9 @@ public function isBuiltin(): bool public function isGenerator(): bool { - return $this->nodeIsOrContainsYield($this->functionLike); + self::$generatorCache ??= new WeakMap(); + + return self::$generatorCache[$this->functionLike] ??= NodeScanner::nodeIsOrContainsYield($this->functionLike); } public function acceptsNamedArguments(): TrinaryLogic @@ -285,11 +296,6 @@ public function acceptsNamedArguments(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->acceptsNamedArguments); } - private function nodeIsOrContainsYield(Node $node): bool - { - return NodeScanner::nodeIsOrContainsYield($node); - } - public function getAsserts(): Assertions { return $this->assertions; From c6a7aff9c14da7b0ea0493f85c11587622ea94e3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 13:27:29 +0200 Subject: [PATCH 365/378] Answer stored fiber asks without suspending FiberScope::getType()/getNativeType() suspended on every ask; for an already-stored expression the round-trip through the fiber machinery hands back the very result the scope's current storage holds. The new findSettledExpressionResult() (the find path's provisional gate, now shared) answers those asks in place under exactly the resumed fast path's conditions; misses and filtered/promoted asks suspend as before. --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 6 +---- src/Analyser/Fiber/FiberScope.php | 24 +++++++++++++++++++ src/Analyser/MutatingScope.php | 11 +++++++++ src/Analyser/NodeScopeResolver.php | 14 +++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index c1497565ebc..d6cd70983c8 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -76,11 +76,7 @@ private function runFiberForNodeCallback( { while (!$fiber->isTerminated()) { if ($request instanceof ExpressionResultRequest) { - // a provisional pre-store is not an answer for outside askers - - // park until the handler stores the final result - $expressionResult = isset($this->provisionalExprIds[spl_object_id($request->expr)]) - ? null - : $storage->findExpressionResult($request->expr); + $expressionResult = $this->findSettledExpressionResult($storage, $request->expr); if ($expressionResult !== null) { $request = $fiber->resume($expressionResult); continue; diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 698cf7fa1d0..4f211c7584c 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -59,6 +59,19 @@ public function toMutatingScope(): MutatingScope /** @api */ public function getType(Expr $node): Type { + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + // the same settled result the suspend round-trip's find path would + // hand back - skip the two fiber switches for the stored ask + $storedResult = $this->findSettledStoredResult($node); + if ($storedResult !== null) { + return $storedResult->getType(); + } + } + /** @var ExpressionResult $expressionResult */ $expressionResult = Fiber::suspend( new ExpressionResultRequest($node, $this), @@ -89,6 +102,17 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + $storedResult = $this->findSettledStoredResult($expr); + if ($storedResult !== null) { + return $storedResult->getNativeType(); + } + } + /** @var ExpressionResult $expressionResult */ $expressionResult = Fiber::suspend( new ExpressionResultRequest($expr, $this), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0f7dc0f97b1..0125fffd6f0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1377,6 +1377,17 @@ public function popExpressionResultStorage(): void * * @internal */ + /** The settled stored result of the current storage - FiberScope's no-switch fast path. */ + protected function findSettledStoredResult(Expr $node): ?ExpressionResult + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage === null) { + return null; + } + + return $this->container->getByType(NodeScopeResolver::class)->findSettledExpressionResult($storage, $node); + } + public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage { return $this->expressionResultStorageStack->getCurrent(); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3ec59a21115..fe006c1f512 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -446,6 +446,20 @@ private function processNodesWithStorage( $this->processPendingFibers($expressionResultStorage); } + /** + * The stored result an outside asker may consume: a handler's provisional + * mid-processing pre-store is not an answer (see + * storeProvisionalExpressionResult()). + */ + public function findSettledExpressionResult(ExpressionResultStorage $storage, Expr $expr): ?ExpressionResult + { + if (isset($this->provisionalExprIds[spl_object_id($expr)])) { + return null; + } + + return $storage->findExpressionResult($expr); + } + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { if (self::$guardNewWorld) { From 25b590e404fe60f789fc2304e03c29164c4d6532 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 15:02:16 +0200 Subject: [PATCH 366/378] Answer invalidation checks from a per-holder index of contained node keys shouldInvalidateExpression() re-scanned the holder's whole subtree with a NodeFinder on every invalidation (after a substring pre-filter on the printed keys, whose compositional-printer invariant needed carve-outs for virtual nodes and key suffixes). The scan establishes a property of the holder's expression alone, so each holder now lazily indexes the node keys of its sub-expressions once - holders are shared across scope copies, so the single subtree scan amortizes over every later check - and the check becomes one array lookup, exact by construction. '$this' invalidations keep the finder: they also match self/static/parent and the current class name, and that name resolution depends on the asking scope. invalidateExpression() was 7.8% of the src/Analyser+src/Rules run; make phpstan user CPU: -2.5%. --- src/Analyser/ExpressionTypeHolder.php | 49 +++++++ src/Analyser/MutatingScope.php | 190 +++++++++++++++++++++++--- 2 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/Analyser/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php index 7477dbf3dcd..44e47098d19 100644 --- a/src/Analyser/ExpressionTypeHolder.php +++ b/src/Analyser/ExpressionTypeHolder.php @@ -2,14 +2,29 @@ namespace PHPStan\Analyser; +use PhpParser\Node; use PhpParser\Node\Expr; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_pop; +use function get_class; +use function is_array; final class ExpressionTypeHolder { + /** + * The node key of every sub-expression, keyed to the classes it appears + * as - what MutatingScope::shouldInvalidateExpression()'s AST scan + * established per invalidation. Holders are shared across scope copies, + * so the one-time subtree scan amortizes over the many invalidation + * checks against the same holder. + * + * @var array, true>>|null + */ + private ?array $containedNodeKeys = null; + public function __construct( private readonly Expr $expr, private readonly Type $type, @@ -18,6 +33,40 @@ public function __construct( { } + /** + * @param callable(Expr): string $keyBuilder + * @return array, true>> + */ + public function getContainedNodeKeys(callable $keyBuilder): array + { + if ($this->containedNodeKeys !== null) { + return $this->containedNodeKeys; + } + + $keys = []; + $stack = [$this->expr]; + while ($stack !== []) { + $node = array_pop($stack); + if ($node instanceof Expr) { + $keys[$keyBuilder($node)][get_class($node)] = true; + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + $stack[] = $subNode; + } elseif (is_array($subNode)) { + foreach ($subNode as $subNodeItem) { + if ($subNodeItem instanceof Node) { + $stack[] = $subNodeItem; + } + } + } + } + } + + return $this->containedNodeKeys = $keys; + } + public static function createYes(Expr $expr, Type $type): self { return new self($expr, $type, TrinaryLogic::createYes()); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0125fffd6f0..cdd5b5659bf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3361,38 +3361,185 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self { + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $invalidated = false; $exprStringToInvalidate = $this->getNodeKey($expressionToInvalidate); - $result = ScopeOps::invalidateExpressionEntries( - $this, - $this->exprPrinter, - $exprStringToInvalidate, - $expressionToInvalidate, - $requireMoreCharacters, - $invalidatingClass, - $this->expressionTypes, - $this->nativeExpressionTypes, - $this->conditionalExpressions, - ); - if ($result === null) { + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprTypeHolder, $exprString, $requireMoreCharacters, $invalidatingClass)) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; + } + + $newConditionalExpressions = []; + foreach ($this->conditionalExpressions as $conditionalExprString => $holders) { + if (count($holders) === 0) { + continue; + } + $firstHolder = $holders[array_key_first($holders)]->getTypeHolder(); + if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $firstHolder, $this->getNodeKey($firstHolder->getExpr()), $requireMoreCharacters, $invalidatingClass)) { + $invalidated = true; + continue; + } + $filteredHolders = []; + foreach ($holders as $key => $holder) { + $shouldKeep = true; + $conditionalTypeHolders = $holder->getConditionExpressionTypeHolders(); + foreach ($conditionalTypeHolders as $conditionalTypeHolderExprString => $conditionalTypeHolder) { + if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder, $conditionalTypeHolderExprString, false, $invalidatingClass)) { + $invalidated = true; + $shouldKeep = false; + break; + } + } + if (!$shouldKeep) { + continue; + } + + $filteredHolders[$key] = $holder; + } + if (count($filteredHolders) <= 0) { + continue; + } + + $newConditionalExpressions[$conditionalExprString] = $filteredHolders; + } + + if (!$invalidated) { return $this; } - /** @var static */ - return ScopeOps::scopeWith( - $this, - $result[0], - $result[1], - $result[2], + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $newConditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, $this->currentlyAssignedExpressions, $this->currentlyAllowedUndefinedExpressions, [], - $this->inFirstLevelStatement, $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, ); } - /** @internal called by ScopeOps */ + private function getIntertwinedRefRootVariableName(Expr $expr): ?string + { + if ($expr instanceof Variable && is_string($expr->name)) { + return $expr->name; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->getIntertwinedRefRootVariableName($expr->var); + } + return null; + } + + private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, ExpressionTypeHolder $exprTypeHolder, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool + { + $expr = $exprTypeHolder->getExpr(); + if ( + $expr instanceof IntertwinedVariableByReferenceWithExpr + && $exprToInvalidate instanceof Variable + && is_string($exprToInvalidate->name) + && ( + $expr->getVariableName() === $exprToInvalidate->name + || $this->getIntertwinedRefRootVariableName($expr->getExpr()) === $exprToInvalidate->name + || $this->getIntertwinedRefRootVariableName($expr->getAssignedExpr()) === $exprToInvalidate->name + ) + ) { + return false; + } + + if ($requireMoreCharacters && $exprStringToInvalidate === $exprString) { + return false; + } + + // Variables will not contain traversable expressions. skip the NodeFinder overhead + if ($expr instanceof Variable && is_string($expr->name) && !$requireMoreCharacters) { + return $exprStringToInvalidate === $exprString; + } + + // getNodeKey() is the pretty-printed expression, and the standard printer is + // compositional: the key of any sub-expression appears verbatim as a substring of + // the key of the expression containing it. So if the invalidated expression's key + // does not appear anywhere in this expression's key, this expression cannot contain + // it and we can skip the expensive AST traversal below. + // Carve-outs where that invariant does not hold: + // - '$this' is special-cased in the visitor to also match self/static/parent, + // - PHPStan's virtual nodes (printed as '__phpstan…') use non-compositional printers + // (e.g. a wrapped variable is printed by name, not as '$name'), + // - keys carrying a getNodeKey() suffix ('/*…*/') are not plain substrings. + if ( + $exprStringToInvalidate !== '$this' + && !str_contains($exprStringToInvalidate, '__phpstan') + && !str_contains($exprStringToInvalidate, '/*') + && !str_contains($exprString, '__phpstan') + && !str_contains($exprString, $exprStringToInvalidate) + ) { + return false; + } + + if ($exprStringToInvalidate === '$this') { + // '$this' also matches self/static/parent and the current class name - + // name resolution depends on this scope, so the holder-cached key + // index below cannot answer it + $nodeFinder = new NodeFinder(); + $expressionToInvalidateClass = get_class($exprToInvalidate); + $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($expressionToInvalidateClass, $exprStringToInvalidate): bool { + if ( + $node instanceof Name + && ( + in_array($node->toLowerString(), ['self', 'static', 'parent'], true) + || ($this->getClassReflection() !== null && $this->getClassReflection()->is($this->resolveName($node))) + ) + ) { + return true; + } + + if (!$node instanceof $expressionToInvalidateClass) { + return false; + } + + return $this->getNodeKey($node) === $exprStringToInvalidate; + }); + + if ($found === null) { + return false; + } + } elseif (!isset($exprTypeHolder->getContainedNodeKeys($this->getNodeKey(...))[$exprStringToInvalidate][get_class($exprToInvalidate)])) { + return false; + } + + if ( + $expr instanceof PropertyFetch + && $requireMoreCharacters + && $this->isReadonlyPropertyFetch($expr, false) + ) { + return false; + } + + if ( + $invalidatingClass !== null + && $requireMoreCharacters + && $this->isPrivatePropertyOfDifferentClass($expr, $invalidatingClass) + ) { + return false; + } + + return true; + } + public function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $invalidatingClass): bool { if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof PropertyFetch) { @@ -3408,7 +3555,6 @@ public function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $i return false; } - private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): self { $result = ScopeOps::invalidateMethodsOnExpression( @@ -4270,7 +4416,7 @@ private function generalizeVariableTypeHolders( $newVariableTypeHolders = []; foreach ($variableTypeHolders as $variableExprString => $variableTypeHolder) { foreach ($generalizedExpressions as $generalizedExprString => $generalizedExpr) { - if (!ScopeOps::shouldInvalidateExpression($this, $this->exprPrinter, $generalizedExprString, $generalizedExpr, $variableTypeHolder->getExpr(), $variableExprString)) { + if (!$this->shouldInvalidateExpression($generalizedExprString, $generalizedExpr, $variableTypeHolder, $variableExprString)) { continue; } From d4608b7aaad2c868deb3b26f3bbaaa98c0a62911 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 15:24:54 +0200 Subject: [PATCH 367/378] Memoize flush-priced answers for repeated rule asks Rules ask about the same (usually synthetic) expression repeatedly across statement boundaries; each parked fiber was resolved by a fresh on-demand walk on a duplicated, discarded storage, so every subsequent ask re-walked the node and its subtree - the single largest reprocessing channel in the corpus census (31% of reprocessing walks, 357k flush walks per run, 99.9% of re-asks answerable from the previous pricing). Unlike the reverted first attempt (436b282a0f), the memo retains no walk results: only the read-variable state snapshot (ReadVariableStateSnapshot, the retained half of askScopeVariableStateMatches) and the two materialized types per expression. A hit fabricates an effect-free eager result at the ask position - exactly where a fresh walk's result would sit - so no scope or callback graph outlives the walk and memory stays at parity. Both flavours are materialized at store time; with the walk contexts hot this costs far less than the re-walks it replaces. make phpstan user CPU: -3.5%, closing the gap to 2.2.x to about +1%. --- src/Analyser/ExpressionResult.php | 24 +++++++ src/Analyser/Fiber/FiberNodeScopeResolver.php | 43 ++++++++++--- src/Analyser/NodeScopeResolver.php | 18 ++++++ src/Analyser/ReadVariableStateSnapshot.php | 62 +++++++++++++++++++ 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/Analyser/ReadVariableStateSnapshot.php diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index c1f11a28b43..aee6b5d5a40 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -501,6 +501,30 @@ private function isScopeAuthoritative(MutatingScope $scope): bool * memoized walk-position type; the caller re-prices the node on the asking * scope instead. */ + /** The retained equivalent of askScopeVariableStateMatches() - see ReadVariableStateSnapshot. */ + public function takeReadVariableStateSnapshot(): ReadVariableStateSnapshot + { + if ($this->expr instanceof Expr\Closure || $this->expr instanceof Expr\ArrowFunction) { + return new ReadVariableStateSnapshot([]); + } + + $states = []; + $positionScope = $this->beforeScope; + $nativePositionScope = $positionScope->doNotTreatPhpDocTypesAsCertain(); + foreach ($this->getReadVariableNames() as $name) { + $knows = $positionScope->hasVariableType($name); + $nativeKnows = $nativePositionScope->hasVariableType($name); + $states[$name] = [ + $knows, + $knows->no() ? null : $positionScope->getVariableType($name), + $nativeKnows, + $nativeKnows->no() ? null : $nativePositionScope->getVariableType($name), + ]; + } + + return new ReadVariableStateSnapshot($states); + } + public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNativeTypes): bool { // same unpromoted position implies same promoted position - skip the diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index d6cd70983c8..cf5f0a4a4d6 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -8,11 +8,14 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\ReadVariableStateSnapshot; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; +use WeakMap; use function array_pop; use function count; use function get_class; @@ -24,6 +27,13 @@ final class FiberNodeScopeResolver extends NodeScopeResolver { + /** + * Last flush-priced answer per asked expression - see processPendingFibers(). + * + * @var WeakMap|null + */ + private ?WeakMap $flushedOnDemandResults = null; + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -145,13 +155,32 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void $fiber = $pending['fiber']; - // Process the synthetic node with a duplicated storage so that the result - // computed from the asker's scope does not poison the real storage. - $expressionResult = $this->processExprOnDemand( - $request->expr, - $request->scope->toMutatingScope(), - $storage->duplicate(), - ); + // Rules ask about the same (usually synthetic) node repeatedly across + // statement boundaries; the answer is reusable whenever nothing the + // expression reads changed since the walk. Only the state snapshot + // and the materialized types are retained - keeping the walk result + // would pin its scope and callback graphs for every parser-cached + // file. The hit is fabricated at the ask position, exactly where a + // fresh walk's result would sit. + $askScope = $request->scope->toMutatingScope(); + $this->flushedOnDemandResults ??= new WeakMap(); + $memoEntry = $this->flushedOnDemandResults[$request->expr] ?? null; + if ($memoEntry !== null && $memoEntry[0]->matches($askScope)) { + $expressionResult = $this->createEagerExpressionResult($askScope, $request->expr, $memoEntry[1], $memoEntry[2]); + } else { + // Process the node with a duplicated storage so that the result + // computed from the asker's scope does not poison the real storage. + $expressionResult = $this->processExprOnDemand( + $request->expr, + $askScope, + $storage->duplicate(), + ); + $this->flushedOnDemandResults[$request->expr] = [ + $expressionResult->takeReadVariableStateSnapshot(), + $expressionResult->getType(), + $expressionResult->getNativeType(), + ]; + } $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index fe006c1f512..530a49cdfca 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -460,6 +460,24 @@ public function findSettledExpressionResult(ExpressionResultStorage $storage, Ex return $storage->findExpressionResult($expr); } + /** An effect-free result carrying eagerly known types, positioned at the given scope. */ + protected function createEagerExpressionResult(MutatingScope $scope, Expr $expr, Type $type, Type $nativeType): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + type: $type, + nativeType: $nativeType, + ); + } + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { if (self::$guardNewWorld) { diff --git a/src/Analyser/ReadVariableStateSnapshot.php b/src/Analyser/ReadVariableStateSnapshot.php new file mode 100644 index 00000000000..3e1c7c289a1 --- /dev/null +++ b/src/Analyser/ReadVariableStateSnapshot.php @@ -0,0 +1,62 @@ + $variableStates + */ + public function __construct(private array $variableStates) + { + } + + public function matches(MutatingScope $askScope): bool + { + if ($this->variableStates === []) { + return true; + } + + $nativeAskScope = $askScope->doNotTreatPhpDocTypesAsCertain(); + foreach ($this->variableStates as $name => [$knows, $type, $nativeKnows, $nativeType]) { + if ( + !$this->flavourMatches($askScope, (string) $name, $knows, $type) + || !$this->flavourMatches($nativeAskScope, (string) $name, $nativeKnows, $nativeType) + ) { + return false; + } + } + + return true; + } + + private function flavourMatches(MutatingScope $scope, string $name, TrinaryLogic $positionKnows, ?Type $positionType): bool + { + $askKnows = $scope->hasVariableType($name); + if ($askKnows->no() && $positionKnows->no()) { + return true; + } + if (!$askKnows->equals($positionKnows)) { + return false; + } + if ($positionType === null) { + return false; + } + + return $scope->getVariableType($name)->equals($positionType); + } + +} From 5fd0e7f7bb33853769f2b09b8c3631974ca8ac97 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 16:13:53 +0200 Subject: [PATCH 368/378] Create merge conditionals from the differing holders only createConditionalExpressions() scanned every holder map in full - twice per merge - although each of its loops only ever selects entries whose our/their/merged holders differ: guards require the merged type to differ from ours, targets likewise, and the exclusion list is only consulted for such entries. mergeVariableHolders() knows exactly which keys those are (everything that is not one shared holder on both sides), so it now reports them and the conditional creation iterates just that diff - typically a handful of keys instead of hundreds of holders. createConditionalExpressions was the largest self-time item in the branch's SPX profile (5.8s of a 3x-inflated scoped run); make phpstan user CPU: -1%. --- src/Analyser/MutatingScope.php | 5 ++++- src/Analyser/ScopeOps.php | 32 +++++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index cdd5b5659bf..d12c611e347 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4056,7 +4056,8 @@ public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = $ourExpressionTypes = $this->expressionTypes; $theirExpressionTypes = $otherScope->expressionTypes; - $mergedExpressionTypes = ScopeOps::mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes); + $differingExpressionKeys = []; + $mergedExpressionTypes = ScopeOps::mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes, $differingExpressionKeys); $conditionalExpressions = ScopeOps::intersectConditionalExpressions($this->conditionalExpressions, $otherScope->conditionalExpressions); if ($preserveVacuousConditionals) { $conditionalExpressions = $this->preserveVacuousConditionalExpressions( @@ -4075,12 +4076,14 @@ public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = $ourExpressionTypes, $theirExpressionTypes, $mergedExpressionTypes, + $differingExpressionKeys, ); $conditionalExpressions = ScopeOps::createConditionalExpressions( $conditionalExpressions, $theirExpressionTypes, $ourExpressionTypes, $mergedExpressionTypes, + $differingExpressionKeys, ); [$mergedExpressionTypes, $mergedNativeTypes] = ScopeOps::finishMerge( diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 30145754daf..36506d664c4 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -188,9 +188,10 @@ public static function scopeWith( * * @param array $ourVariableTypeHolders * @param array $theirVariableTypeHolders + * @param array $differingKeys * @return array */ - public static function mergeVariableHolders(array $ourVariableTypeHolders, array $theirVariableTypeHolders): array + public static function mergeVariableHolders(array $ourVariableTypeHolders, array $theirVariableTypeHolders, array &$differingKeys = []): array { $intersectedVariableTypeHolders = []; $globalVariableCallback = static fn (Node $node) => $node instanceof Variable && is_string($node->name) && in_array($node->name, Scope::SUPERGLOBAL_VARIABLES, true); @@ -202,8 +203,10 @@ public static function mergeVariableHolders(array $ourVariableTypeHolders, array continue; } + $differingKeys[$exprString] = true; $intersectedVariableTypeHolders[$exprString] = $variableTypeHolder->and($theirVariableTypeHolders[$exprString]); } else { + $differingKeys[$exprString] = true; $expr = $variableTypeHolder->getExpr(); $containsSuperGlobal = $expr->getAttribute(self::CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME); @@ -224,6 +227,7 @@ public static function mergeVariableHolders(array $ourVariableTypeHolders, array continue; } + $differingKeys[$exprString] = true; $expr = $variableTypeHolder->getExpr(); $containsSuperGlobal = $expr->getAttribute(self::CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME); @@ -351,6 +355,7 @@ public static function intersectConditionalExpressions(array $ourConditionalExpr * @param array $ourExpressionTypes * @param array $theirExpressionTypes * @param array $mergedExpressionTypes + * @param array $differingKeys * @return array */ public static function createConditionalExpressions( @@ -358,6 +363,7 @@ public static function createConditionalExpressions( array $ourExpressionTypes, array $theirExpressionTypes, array $mergedExpressionTypes, + array $differingKeys, ): array { $newVariableTypes = $ourExpressionTypes; @@ -368,7 +374,11 @@ public static function createConditionalExpressions( // branch — but it remains a valid conditional *target*, so only exclude // it from guard selection instead of dropping it entirely. $guardsToExclude = []; - foreach ($theirExpressionTypes as $exprString => $holder) { + foreach ($differingKeys as $exprString => $unusedDiffMarker) { + if (!array_key_exists($exprString, $theirExpressionTypes)) { + continue; + } + $holder = $theirExpressionTypes[$exprString]; if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } @@ -389,7 +399,11 @@ public static function createConditionalExpressions( } $typeGuards = []; - foreach ($newVariableTypes as $exprString => $holder) { + foreach ($differingKeys as $exprString => $unusedDiffMarker) { + if (!array_key_exists($exprString, $newVariableTypes)) { + continue; + } + $holder = $newVariableTypes[$exprString]; if ($holder->getExpr() instanceof VirtualNode) { continue; } @@ -429,7 +443,11 @@ public static function createConditionalExpressions( $guardIsSuperTypeOfTheirExprCache = []; $theirExprIsSuperTypeOfGuardCache = []; - foreach ($newVariableTypes as $exprString => $holder) { + foreach ($differingKeys as $exprString => $unusedDiffMarker) { + if (!array_key_exists($exprString, $newVariableTypes)) { + continue; + } + $holder = $newVariableTypes[$exprString]; if ($holder->getExpr() instanceof VirtualNode) { continue; } @@ -490,7 +508,11 @@ public static function createConditionalExpressions( } } - foreach ($mergedExpressionTypes as $exprString => $mergedExprTypeHolder) { + foreach ($differingKeys as $exprString => $unusedDiffMarker) { + if (!array_key_exists($exprString, $mergedExpressionTypes)) { + continue; + } + $mergedExprTypeHolder = $mergedExpressionTypes[$exprString]; if (array_key_exists($exprString, $ourExpressionTypes)) { continue; } From 553c3a8063fe3c781851e76b30bfee94b159e88f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 19:48:33 +0200 Subject: [PATCH 369/378] Skip loop verification passes whose entry scope did not change Loop convergence re-walked the body until the exit scope stabilized; 94% of converging loops converge exactly one pass after the last change, so that final re-walk is pure verification. Walking is deterministic in the entry scope: when a pass's merged entry equals the previous pass's entry, the exit is the previous exit and the walk is skipped (27.5% of convergence re-walks on the scoped corpus). Measured CPU-neutral on make phpstan - the skipped bodies are small - but the wasted walks are gone and larger loop bodies benefit proportionally. --- src/Analyser/NodeScopeResolver.php | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 530a49cdfca..a6634914877 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -542,11 +542,19 @@ private function resolveBackwardGotoScope( { $bodyScope = $scope; $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; if ($mergeBodyScopeEachIteration) { $bodyScope = $bodyScope->mergeWith($scope); } + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $tempStorage = $storage->duplicate(); $bodyScopeResult = $this->processStmtNodesInternal( $parentNode, @@ -1800,9 +1808,17 @@ static function () use ($condResult, $emptyArrayType): Type { $scope->popExpressionResultStorage(); } $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); $scope->pushExpressionResultStorage($storage); try { @@ -2065,9 +2081,17 @@ static function () use ($condResult, $emptyArrayType): Type { if ($context->isTopLevel()) { $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); $scope->pushExpressionResultStorage($storage); try { @@ -2169,9 +2193,18 @@ static function () use ($condResult, $emptyArrayType): Type { $originalStorage = $storage; if ($context->isTopLevel()) { + $prevEntryScope = null; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit (and repeats only idempotent + // merges into the final scope), so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); $scope->pushExpressionResultStorage($storage); try { @@ -2303,10 +2336,18 @@ static function () use ($condResult, $emptyArrayType): Type { if ($context->isTopLevel()) { $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; $storage = $originalStorage->duplicate(); $bodyScope = $bodyScope->mergeWith($initScope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $scope->pushExpressionResultStorage($storage); try { if ($lastCondExpr !== null) { From b7fc37d2fe5f6fbf95b117b28eb30c4405224ef8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 20:00:39 +0200 Subject: [PATCH 370/378] Batch type specifications into one scope copy per application specifyExpressionType() copied both holder maps and constructed a scope per specification - and per array-dim level through its parent recursion. The body now writes in place on an unpublished working copy (specifyExpressionTypeInPlace, same contract as processConditionalExpressionsAfterSpecifying), single callers open one copy, and applySpecifiedTypes() shares one working copy across its whole batch, publishing only when another scope derivation interleaves. Measured CPU-neutral on make phpstan - the whole-map COW copies turn out cheaper than modeled at typical holder-map sizes - but one copy per batch replaces one per specification-and-dim-level, and the in-place seam is what future batching builds on. --- src/Analyser/MutatingScope.php | 119 ++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d12c611e347..a360e1d5ba0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3214,14 +3214,49 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { - if ($expr instanceof Scalar) { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { return $this; } + $scope = $this->openSpecificationScope(); + $scope->specifyExpressionTypeInPlace($expr, $type, $nativeType, $certainty); + + return $scope; + } + + /** An unpublished copy of this scope that in-place specification may mutate. */ + private function openSpecificationScope(): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function isSpecifyExpressionTypeNoop(Expr $expr, Type $type): bool + { + if ($expr instanceof Scalar) { + return true; + } + if ($expr instanceof ConstFetch) { $loweredConstName = strtolower($expr->name->toString()); if (in_array($loweredConstName, ['true', 'false', 'null'], true)) { - return $this; + return true; } } @@ -3232,11 +3267,25 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, 'is_file', 'file_exists', ], true)) { - return $this; + return true; } } - $scope = $this; + return false; + } + + /** + * The body of specifyExpressionType() writing straight into this scope's + * holder maps - only to be called on an unpublished scope (see + * openSpecificationScope()). Batching callers avoid one whole-map copy and + * scope construction per specification (and per array-dim level). + */ + private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): void + { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { + return; + } + if ( $expr instanceof Expr\ArrayDimFetch && $expr->dim !== null @@ -3245,9 +3294,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getScopeStateType($expr->dim)->toArrayKey(); + $dimType = $this->getScopeStateType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getScopeStateType($expr->var); + $exprVarType = $this->getScopeStateType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -3268,10 +3317,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } - $scope = $scope->specifyExpressionType( + $this->specifyExpressionTypeInPlace( $expr->var, $varType, - $scope->getScopeStateNativeType($expr->var), + $this->getScopeStateNativeType($expr->var), $certainty, ); } @@ -3283,31 +3332,15 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } $exprString = $this->getNodeKey($expr); - $expressionTypes = $scope->expressionTypes; - $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); - $nativeTypes = $scope->nativeExpressionTypes; - $nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); - - /** @var static $scope */ - $scope = ScopeOps::scopeWith( - $this, - $expressionTypes, - $nativeTypes, - $this->conditionalExpressions, - $this->currentlyAssignedExpressions, - $this->currentlyAllowedUndefinedExpressions, - $this->inFunctionCallsStack, - $this->inFirstLevelStatement, - $this->afterExtractCall, - ); + $this->expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); + $this->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); if ($expr instanceof AlwaysRememberedExpr) { - return $scope->specifyExpressionType($expr->expr, $type, $nativeType, $certainty); + $this->specifyExpressionTypeInPlace($expr->expr, $type, $nativeType, $certainty); } - - return $scope; } + public function assignExpression(Expr $expr, Type $type, Type $nativeType): self { $scope = $this; @@ -3774,6 +3807,10 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self }); $scope = $this; + // one unpublished working copy takes all in-place specifications of the + // batch; operations that go through other scope derivations publish it + // and a fresh copy opens on the next specification + $scopeIsWorkingCopy = false; $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; @@ -3791,6 +3828,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } else { $scope = $scope->unsetExpression($expr); } + $scopeIsWorkingCopy = false; continue; } @@ -3863,7 +3901,13 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $newType = $trackedType !== null ? TypeCombinator::intersect($evaluated, $trackedType) : $evaluated; $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($evaluatedNative, $trackedNativeType) : $evaluatedNative; - $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } $holderType = array_key_exists($exprString, $scope->expressionTypes) ? $scope->expressionTypes[$exprString]->getType() @@ -3876,10 +3920,17 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); + $scopeIsWorkingCopy = false; } else { $newType = $trackedType !== null ? TypeCombinator::intersect($type, $trackedType) : $type; $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($type, $trackedNativeType) : $type; - $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } } } else { if ($type instanceof NeverType || $trackedType instanceof NeverType) { @@ -3891,7 +3942,13 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } $newNativeType = $trackedNativeType !== null ? TypeCombinator::remove($trackedNativeType, $type) : $newType; - $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } } $holderType = array_key_exists($exprString, $scope->expressionTypes) From 230acf30edf0a7ab9ba4760d62fd5ca7408e3ae4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 21:33:15 +0200 Subject: [PATCH 371/378] Run engine-feeding node-callback gatherers synchronously at the emission position Body walks pair a gathering closure (impure points, invalidations, execution ends, return statements) with the rule-facing node callback and read the gathered arrays as soon as the walk returns. With the whole composite running inside a fiber, a rule parking on an unsettled expression deferred the gathering past that read - a closure assigning static::$prop lost its propertyAssign impure point whenever a rule suspended on the PropertyAssignNode, so the invocation was reported as having no side effects. GatheringNodeCallback makes the pairing explicit: FiberNodeScopeResolver unwraps it and runs the gatherer at the emission position, deferring only the rule-facing remainder to a fiber. --- src/Analyser/ExprHandler/FuncCallHandler.php | 6 +-- src/Analyser/ExprHandler/NewHandler.php | 6 +-- src/Analyser/Fiber/FiberNodeScopeResolver.php | 10 ++++ src/Analyser/GatheringNodeCallback.php | 52 +++++++++++++++++++ src/Analyser/NodeScopeResolver.php | 30 +++++------ 5 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 src/Analyser/GatheringNodeCallback.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index a53cd704db8..1addebebf0c 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; @@ -263,7 +264,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($firstParamName !== null) { $arrayWalkArrayArg = $normalizedExpr->getArgs()[0]->value; - $nodeCallbackForArgs = static function (Node $node, Scope $scope) use ($nodeCallback, $callbackArg, $firstParamName, &$arrayWalkValueTypes): void { + $nodeCallbackForArgs = new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($callbackArg, $firstParamName, &$arrayWalkValueTypes): void { if ($node instanceof ClosureReturnStatementsNode && $node->getClosureExpr() === $callbackArg) { $types = []; $nativeTypes = []; @@ -291,8 +292,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ]; } } - $nodeCallback($node, $scope); - }; + }, $nodeCallback); } } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index dedfbfc8f6d..25caf1fff47 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -126,8 +127,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($constructorReflection->getDeclaringClass()->getName() === $classReflection->getName()) { $constructorResult = null; - $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $classReflection, &$constructorResult): void { - $nodeCallback($node, $scope); + $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($classReflection, &$constructorResult): void { if (!$node instanceof MethodReturnStatementsNode) { return; } @@ -145,7 +145,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return; } $constructorResult = $node; - }, StatementContext::createTopLevel()); + }, $nodeCallback), StatementContext::createTopLevel()); if ($constructorResult !== null) { $throwPoints = array_map(static fn (ThrowPoint $point): InternalThrowPoint => InternalThrowPoint::createFromPublic($point, $scope), $constructorResult->getStatementResult()->getThrowPoints()); $impurePoints = $constructorResult->getImpurePoints(); diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index cf5f0a4a4d6..27d8c4d636f 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -7,6 +7,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\ReadVariableStateSnapshot; use PHPStan\Analyser\NodeScopeResolver; @@ -44,6 +45,15 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + // Engine-feeding gatherers must observe the node at the emission + // position - their arrays are read as soon as the enclosing body walk + // returns. Only the rule-facing remainder may be deferred to a fiber; + // a rule parking on an unsettled expression must not delay gathering. + while ($nodeCallback instanceof GatheringNodeCallback) { + ($nodeCallback->getGatherer())($node, $scope->toFiberScope()); + $nodeCallback = $nodeCallback->getInner(); + } + if ($nodeCallback instanceof NoopNodeCallback) { // fibers exist solely to let node callbacks ask about types, // a noop callback does not need one diff --git a/src/Analyser/GatheringNodeCallback.php b/src/Analyser/GatheringNodeCallback.php new file mode 100644 index 00000000000..6257f53184d --- /dev/null +++ b/src/Analyser/GatheringNodeCallback.php @@ -0,0 +1,52 @@ +gatherer = $gatherer; + $this->inner = $inner; + } + + public function __invoke(Node $node, Scope $scope): void + { + ($this->inner)($node, $scope); + ($this->gatherer)($node, $scope); + } + + /** @return callable(Node, Scope): void */ + public function getGatherer(): callable + { + return $this->gatherer; + } + + /** @return callable(Node, Scope): void */ + public function getInner(): callable + { + return $this->inner; + } + +} diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a6634914877..3cdc541b79f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1040,8 +1040,7 @@ public function processStmtNode( $bodyStorage = $storage->duplicate(); $scope->pushExpressionResultStorage($bodyStorage); try { - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - $nodeCallback($node, $scope); + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { if ($scope->getFunction() !== $functionScope->getFunction()) { return; } @@ -1070,7 +1069,7 @@ public function processStmtNode( } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( $stmt, @@ -1207,8 +1206,7 @@ public function processStmtNode( $bodyStorage = $storage->duplicate(); $scope->pushExpressionResultStorage($bodyStorage); try { - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - $nodeCallback($node, $scope); + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, new GatheringNodeCallback(function (Node $node, Scope $scope) use ($methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { if ($scope->getFunction() !== $methodScope->getFunction()) { return; } @@ -1246,7 +1244,7 @@ public function processStmtNode( } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); $methodReflection = $methodScope->getFunction(); if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { @@ -1372,7 +1370,7 @@ public function processStmtNode( } $hasAssign = false; $currentScope = $scope; - $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { + $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($currentScope, &$hasAssign): void { if ( ($node instanceof VariableAssignNode || $node instanceof PropertyAssignNode) && $scope->getAnonymousFunctionReflection() === $currentScope->getAnonymousFunctionReflection() @@ -1380,8 +1378,7 @@ public function processStmtNode( ) { $hasAssign = true; } - $nodeCallback($node, $scope); - }, ExpressionContext::createTopLevel()); + }, $nodeCallback), ExpressionContext::createTopLevel()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -3685,8 +3682,7 @@ private function processClosureNodeInternal( $gatheredYieldStatementsWithScope = []; $closureImpurePoints = []; $invalidateExpressions = []; - $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { - $nodeCallback($node, $scope); + $closureStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $scope) use (&$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { return; } @@ -3719,7 +3715,7 @@ private function processClosureNodeInternal( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); $gatheredReturnStatementsWithScope[] = [$node, $scope]; - }; + }, $nodeCallback); if (count($byRefUses) === 0) { $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); @@ -3907,8 +3903,7 @@ public function processArrowFunctionNode( // feeds ClosureTypeResolver::buildClosureTypeForArrowFunction(). $arrowFunctionImpurePoints = []; $invalidateExpressions = []; - $arrowFunctionStmtsCallback = static function (Node $node, Scope $innerScope) use ($nodeCallback, $arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { - $nodeCallback($node, $innerScope); + $arrowFunctionStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $innerScope) use ($arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { if ($innerScope->getAnonymousFunctionReflection() !== $arrowFunctionScope->getAnonymousFunctionReflection()) { return; } @@ -3930,7 +3925,7 @@ public function processArrowFunctionNode( true, ); $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - }; + }, $nodeCallback); $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $arrowFunctionStmtsCallback, ExpressionContext::createTopLevel()); @@ -4235,8 +4230,7 @@ private function processPropertyHooks( $gatheredReturnStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal(new PropertyHookStatementNode($hook), $stmts, $hookScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { - $nodeCallback($node, $scope); + $statementResult = $this->processStmtNodesInternal(new PropertyHookStatementNode($hook), $stmts, $hookScope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { if ($scope->getFunction() !== $hookScope->getFunction()) { return; } @@ -4262,7 +4256,7 @@ private function processPropertyHooks( } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); $this->callNodeCallback($nodeCallback, new PropertyHookReturnStatementsNode( $hook, From 21492ff0ec817cf8e6cb49fd88c34dd9929cfca1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 22:21:06 +0200 Subject: [PATCH 372/378] Align the isset verdict gate with 2.2.x and pin null in falsey isset of native-typed properties Three connected changes to the isset/empty/?? verdict machinery: The maybe-uninitialized gate in IssetabilityResolution::isSet() drops the nativeHasDefaultValue conjunct - the old-world MutatingScope::issetCheck() never had it, only the rule side does (the two deliberately diverge upstream). In its place the gate learns to read conditional-expression entries about the fetch as initialization evidence: such entries exist only when the fetch was narrowed in an evaluated condition, and evaluating a condition reads the fetch, which would have thrown on an uninitialized typed property. A typed-property read witnesses initialization. IssetHandler evaluates its verdict and narrowing callbacks on the post-revert scope instead of the scope before the subjects were processed: revertNonNullability() leaves an originally-untracked nullable chain link tracked at its original type, and that state is what the old world's narrowing evaluation saw. The falsey narrowing of a single isset() subject pins a native-typed property to null even when the verdict is maybe, as long as the inner chain is fully set: the maybe can only mean a nullable value or a possibly uninitialized property, and reading an uninitialized typed property throws instead of yielding a value - so in the !isset() branch any read that completes yields null. --- .../Helper/DefaultNarrowingHelper.php | 20 +++++++++++++++++ src/Analyser/ExprHandler/IssetHandler.php | 22 +++++++++++++------ src/Analyser/IssetabilityDescriptor.php | 3 ++- src/Analyser/IssetabilityLinkInfo.php | 15 +++++++++++++ src/Analyser/IssetabilityResolution.php | 2 +- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 403c5d9ccc5..f8b9a6fe788 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -533,6 +533,26 @@ public function createIssetSingleSubjectNonTrueTypes( return $exprType; } + // A maybe verdict on a native-typed property whose inner chain is fully + // set can only mean "nullable value" or "maybe uninitialized". Reading an + // uninitialized typed property throws instead of yielding a value, so in + // the !isset() branch any read that completes yields null - the null pin + // is sound for both. + if ($isset === null && $isNullable) { + $resolution = $varResult->getIssetabilityResolution($s, false); + $link = $resolution->getLink(); + $inner = $resolution->getInner(); + if ( + $link->isProperty() + && $link->isReflectionNative() + && $link->hasNativeType() + && !$link->isVirtual()->yes() + && ($inner === null || $inner->isSet(static fn (): bool => true) === true) + ) { + return $exprType; + } + } + if ( $issetExpr instanceof ArrayDimFetch && $issetExpr->dim !== null diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index eefd6955cfb..86d24567c05 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -132,6 +132,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); + // The verdict and narrowing evaluate on the post-revert scope, not + // $beforeScope: revertNonNullability() leaves an originally-untracked + // nullable subject tracked at its original type (certainty yes), and the + // isSet() gate reads that as "the subject's value state is known" - + // !isset($this->prop) may then pin the property to null. Evaluating on + // $beforeScope would hide the device's holders from the gate. + $afterScope = $scope; + // lazily memoized multi-subject conjunction fold (ask-independent) $foldAccTypes = null; @@ -143,10 +151,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: static function (bool $nativeTypesPromoted) use ($varResults, $beforeScope): Type { + typeCallback: static function (bool $nativeTypesPromoted) use ($varResults, $afterScope): Type { $issetResult = true; foreach ($varResults as $varResult) { - $result = $varResult->getIssetabilityResolution($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static function (Type $type): ?bool { + $result = $varResult->getIssetabilityResolution($nativeTypesPromoted ? $afterScope->doNotTreatPhpDocTypesAsCertain() : $afterScope, false)->isSet(static function (Type $type): ?bool { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; @@ -171,10 +179,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return new ConstantBooleanType($issetResult); }, - specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $beforeScope, &$foldAccTypes): SpecifiedTypes { + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $afterScope, &$foldAccTypes): SpecifiedTypes { // type of an already-processed chain link, read from its captured // result on the evaluation point - never re-walked through the scope - $evaluationScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $evaluationScope = $nativeTypesPromoted ? $afterScope->doNotTreatPhpDocTypesAsCertain() : $afterScope; $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope, $nodeScopeResolver); if (count($expr->vars) === 0 || $context->null()) { @@ -223,8 +231,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $accExpr = new Isset_([$expr->vars[0]], $expr->getAttributes()); $accTypes = $makeSubjectTypes($expr->vars[0], $varResults[0]); - $accTruthyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createTruthy())); - $accFalseyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createFalsey())); + $accTruthyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); for ($i = 1, $varCount = count($expr->vars); $i < $varCount; $i++) { $rightExprNode = new Isset_([$expr->vars[$i]], $expr->getAttributes()); @@ -250,7 +258,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); - $accFalseyScope = $beforeScope->applySpecifiedTypes($accTypes($beforeScope, TypeSpecifierContext::createFalsey())); + $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); } $foldAccTypes = $accTypes; diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php index d31bf4534c1..3ac913dc4a2 100644 --- a/src/Analyser/IssetabilityDescriptor.php +++ b/src/Analyser/IssetabilityDescriptor.php @@ -125,7 +125,7 @@ public function resolve(MutatingScope $scope, bool $useNativeTypes, Expr $expr): $propertyReflection = $reflectionResolver($scope); if ($propertyReflection === null) { return new IssetabilityResolution( - IssetabilityLinkInfo::property(null, $propertyFetch, false, false, TrinaryLogic::createNo(), new NeverType(), new NeverType(), false, false, false, false, false, false, false), + IssetabilityLinkInfo::property(null, $propertyFetch, false, false, TrinaryLogic::createNo(), new NeverType(), new NeverType(), false, false, false, false, false, false, false, false), $inner, ); } @@ -148,6 +148,7 @@ public function resolve(MutatingScope $scope, bool $useNativeTypes, Expr $expr): $propertyReflection->getWritableType(), $hasNativeType ? $propertyReflection->getNativeType() : new NeverType(), $scope->hasExpressionType($propertyFetch)->yes(), + isset($scope->getConditionalExpressions()[$scope->getNodeKey($propertyFetch)]), $initializedThisProperty, $nativeReflection !== null, $nativeReflection !== null && $nativeReflection->isPromoted(), diff --git a/src/Analyser/IssetabilityLinkInfo.php b/src/Analyser/IssetabilityLinkInfo.php index a347a8039fd..a59416d6b99 100644 --- a/src/Analyser/IssetabilityLinkInfo.php +++ b/src/Analyser/IssetabilityLinkInfo.php @@ -40,6 +40,7 @@ private function __construct( private ?TrinaryLogic $isVirtual = null, private ?Type $nativeType = null, private bool $hasExpressionTypeOfFetch = false, + private bool $hasConditionalExpressionsOfFetch = false, private bool $initializedThisProperty = false, private bool $nativeReflectionExists = false, private bool $nativeIsPromoted = false, @@ -79,6 +80,7 @@ public static function property( Type $writableType, Type $nativeType, bool $hasExpressionTypeOfFetch, + bool $hasConditionalExpressionsOfFetch, bool $initializedThisProperty, bool $nativeReflectionExists, bool $nativeIsPromoted, @@ -97,6 +99,7 @@ public static function property( isVirtual: $isVirtual, nativeType: $nativeType, hasExpressionTypeOfFetch: $hasExpressionTypeOfFetch, + hasConditionalExpressionsOfFetch: $hasConditionalExpressionsOfFetch, initializedThisProperty: $initializedThisProperty, nativeReflectionExists: $nativeReflectionExists, nativeIsPromoted: $nativeIsPromoted, @@ -245,6 +248,18 @@ public function hasExpressionTypeOfFetch(): bool return $this->hasExpressionTypeOfFetch; } + /** + * Whether the scope holds conditional-expression entries about the fetch. + * Such entries exist only when the fetch was narrowed in an evaluated + * condition - and evaluating a condition READS the fetch, which would have + * thrown on an uninitialized typed property. A typed-property read + * witnesses initialization. + */ + public function hasConditionalExpressionsOfFetch(): bool + { + return $this->hasConditionalExpressionsOfFetch; + } + public function isInitializedThisProperty(): bool { return $this->initializedThisProperty; diff --git a/src/Analyser/IssetabilityResolution.php b/src/Analyser/IssetabilityResolution.php index 78365891310..ff48cfe6a6e 100644 --- a/src/Analyser/IssetabilityResolution.php +++ b/src/Analyser/IssetabilityResolution.php @@ -97,7 +97,7 @@ public function isSet(callable $typeCallback, ?bool $result = null): ?bool $link->hasNativeType() && !$link->isVirtual()->yes() && !$link->hasExpressionTypeOfFetch() - && !$link->nativeHasDefaultValue() + && !$link->hasConditionalExpressionsOfFetch() && (!$link->nativeReflectionExists() || !$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) ) { return $this->inner?->isSetUndefined(); From cf3e581a1c110686e14de65c4c9e8a6ed7f704b2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 23:02:44 +0200 Subject: [PATCH 373/378] Store the assign-target property fetch's pre-assign result for parked askers The raw target fetch of a property assignment is emitted to node callbacks at the top of processAssignVar() (rules and DependencyResolver listen on it and ask its type), but the assign flow never processed it as a read - under the fiber engine those askers parked forever and were flushed through the on-demand path, tripping the PHPSTAN_GUARD_NW diagnostic on ~320 suite tests. The property branches now price the target once at the pre-assign position, consuming the receiver's and name's stored results; storing it resumes the parked fibers with the pre-assign type. The pending-fiber guard also learns the same processed-node check the other three guards already have: a node whose per-body storage was released since it was stored (class-level rules asking about gathered method-body expressions) legitimately re-prices through the on-demand rule bridge and must not be flagged. --- src/Analyser/ExprHandler/AssignHandler.php | 9 +++++++++ src/Analyser/Fiber/FiberNodeScopeResolver.php | 12 ++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 93f5a7c966b..d75d3626dcf 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -871,6 +871,12 @@ public function processAssignVar( } $scopeBeforeAssignEval = $scope; + // The raw target fetch was emitted to node callbacks at the top of + // processAssignVar() but the assign flow never processes it as a + // read. Price and store it once here, consuming the receiver's and + // name's stored results, so askers parked on it (DependencyResolver, + // property rules) resume with its pre-assign type. + $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $scopeBeforeAssignEval, $storage, new NoopNodeCallback(), $context->enterDeep()); $result = $processExprCallback($scope); $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -983,6 +989,9 @@ public function processAssignVar( } $scopeBeforeAssignEval = $scope; + // Same as the PropertyFetch branch above: the emitted target fetch + // needs a stored result for parked askers. + $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $scopeBeforeAssignEval, $storage, new NoopNodeCallback(), $context->enterDeep()); $result = $processExprCallback($scope); $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 27d8c4d636f..9f3656252c5 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -152,8 +152,16 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void // on-demand path here. A node from the file's parsed AST left pending // means a rule asked about its type but it was never processed and // stored during natural traversal - a gap to fix at the producing - // handler. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. - if (self::$guardNewWorld && isset(self::$guardRealExprIds[spl_object_id($request->expr)])) { + // handler. A node that WAS stored but whose per-body storage has been + // released since (class-level rules asking about gathered method-body + // exprs) is fine - the on-demand re-price below is the rule-facing + // bridge for those, same as in the other guards' processed check. + // Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. + if ( + self::$guardNewWorld + && isset(self::$guardRealExprIds[spl_object_id($request->expr)]) + && !isset(self::$guardProcessedExprIds[spl_object_id($request->expr)]) + ) { throw new ShouldNotHappenException(sprintf( 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', get_class($request->expr), From 525f050b7ef74551a4982c7fccc39df71b1560c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 23:15:04 +0200 Subject: [PATCH 374/378] Price closure parameter defaults without a scope walk A parameter default is a constant expression - resolve it through InitializerExprTypeResolver like parameter defaults elsewhere, instead of Scope::getType() on a node the single pass has not walked. --- src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index a3c2f6b9bb6..21eb20920f2 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -28,6 +28,8 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; @@ -74,6 +76,7 @@ final class ClosureTypeResolver implements PerFileAnalysisResettable public function __construct( private NodeScopeResolver $nodeScopeResolver, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { $this->cachedTypes = new WeakMap(); @@ -712,7 +715,9 @@ private function buildParametersAndAcceptors( ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), $param->variadic, - $param->default !== null ? $scope->getType($param->default) : null, + // a default is a constant expression - price it without a scope + // walk, the same way parameter defaults are priced elsewhere + $param->default !== null ? $this->initializerExprTypeResolver->getType($param->default, InitializerExprContext::fromScope($scope)) : null, ); } From c923c62661f5796e079e1de8a4a5de4dc2110f3b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 9 Jul 2026 23:15:04 +0200 Subject: [PATCH 375/378] Create the function call's impure point after its arguments are processed The side-effect flip parameters (print_r's $return, var_export's, ...) read an argument's type to decide whether the call has side effects. At the pre-args position that argument is not walked yet; after processArgs() its result is stored and the read is answered from it. --- src/Analyser/ExprHandler/FuncCallHandler.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 1addebebf0c..3e1be0f5308 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -172,10 +172,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // normalization, the impure point and the throw points - generics are // resolved type-driven by processArgs() into $resolvedParametersAcceptor. $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); - $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); - if ($impurePoint !== null) { - $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); - } } else { $impurePoints[] = new ImpurePoint( $scope, @@ -306,6 +302,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + if ($functionReflection !== null) { + // created after the args were processed - the side-effect flip + // parameters (print_r's $return, ...) read an argument's type, which + // is only available once its result is stored + $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); + if ($impurePoint !== null) { + $impurePoints[] = new ImpurePoint($scopeBeforeArgs, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); + } + } + if ($arrayWalkValueTypes !== null && $arrayWalkArrayArg !== null) { $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); From e940ad1b90e5ba59ce49f7df3dd72a54e6352fdf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 16 Jul 2026 16:46:16 +0200 Subject: [PATCH 376/378] Restore the Array_ import dropped when replaying onto the ScopeOps extraction Upstream removed the then-unused import from MutatingScope; the branch's applySpecifiedTypes still guards array literals with instanceof Array_, which silently matched the nonexistent PHPStan\Analyser\Array_ and let array literals into the scope's tracked expressions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8 --- src/Analyser/MutatingScope.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a360e1d5ba0..92e488799a9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -6,6 +6,7 @@ use PhpParser\Node\Arg; use PhpParser\Node\ComplexType; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Match_; From 9d3d921e388551905955eb66cb9a05fe5fda64ef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 16 Jul 2026 16:46:16 +0200 Subject: [PATCH 377/378] Update test expectations after the rebase onto 2.2.x array-merge2: restore the key order upstream moved when getSortedTypes() stopped sorting in place. preg_replace_callback: the flags-refined closure parameter makes the arrow body's return type precise, so returning $m[0] under PREG_OFFSET_CAPTURE is correctly reported as returning an array where a string is required. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8 --- tests/PHPStan/Analyser/nsrt/array-merge2.php | 2 +- .../Rules/Functions/CallToFunctionParametersRuleTest.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-merge2.php b/tests/PHPStan/Analyser/nsrt/array-merge2.php index 0ca400fec81..f0d86e61b6a 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge2.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge2.php @@ -22,7 +22,7 @@ public function arrayMergeArrayShapes($array1, $array2): void assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ['foo' => 3])); assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ...[['foo' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge(rand(0, 1) ? $array1 : $array2, [])); - assertType("array{bar?: 3, foo?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); + assertType("array{foo?: 3, bar?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge([], ...[rand(0, 1) ? $array1 : $array2])); assertType("array{foo: 1, bar: 2, 0: 2, 1: 3}", array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 44bc0a56d08..398c4df35e9 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2934,6 +2934,14 @@ public function testConstantParameterCheck(): void 'Constant PREG_SPLIT_NO_EMPTY is not allowed for parameter #6 $flags of function preg_replace_callback.', 110, ], + [ + 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string, int<-1, max>} given.', + 113, + ], + [ + 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string|null, int<-1, max>} given.', + 116, + ], [ 'Constant PREG_SPLIT_NO_EMPTY is not allowed for parameter #5 $flags of function preg_replace_callback_array.', 119, From e38aaddf08b9fd62c1c1835f7da9969ffefd0875 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 16 Jul 2026 16:55:28 +0200 Subject: [PATCH 378/378] Remove the superseded ScopeOps invalidation helpers after the rebase The branch answers invalidation checks from the per-holder index of contained node keys in MutatingScope, so the extracted invalidateExpressionEntries/shouldInvalidateExpression/ containsExpressionToInvalidate/buildTypeSpecifications helpers have no callers left. Also drop the baseline entries for the unused-use closure errors that upstream's include/require handling (#6056) resolved. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8 --- phpstan-baseline.neon | 12 -- src/Analyser/MutatingScope.php | 2 +- src/Analyser/ScopeOps.php | 259 +-------------------------------- 3 files changed, 6 insertions(+), 267 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 75eab4fc421..fc6c48fdbd1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -126,12 +126,6 @@ parameters: count: 1 path: src/Collectors/Registry.php - - - rawMessage: Anonymous function has an unused use $container. - identifier: closure.unusedUse - count: 1 - path: src/Command/CommandHelper.php - - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass @@ -747,12 +741,6 @@ parameters: count: 1 path: src/Testing/LevelsTestCase.php - - - rawMessage: Anonymous function has an unused use $container. - identifier: closure.unusedUse - count: 1 - path: src/Testing/PHPStanTestCase.php - - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 92e488799a9..c04defc516d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3574,7 +3574,7 @@ private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr return true; } - public function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $invalidatingClass): bool + private function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $invalidatingClass): bool { if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof PropertyFetch) { $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 36506d664c4..ed76f9477a2 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -4,18 +4,13 @@ use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Name; use PhpParser\NodeFinder; -use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; use PHPStan\Parser\ArrayMapArgVisitor; -use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; @@ -24,15 +19,10 @@ use PHPStan\Type\Type; use function array_filter; use function array_key_exists; -use function array_key_first; +use function array_keys; use function count; -use function get_class; use function in_array; -use function is_array; use function is_string; -use function str_contains; -use function strlen; -use function usort; /** * Hot scope-table operations extracted from MutatingScope. @@ -374,7 +364,7 @@ public static function createConditionalExpressions( // branch — but it remains a valid conditional *target*, so only exclude // it from guard selection instead of dropping it entirely. $guardsToExclude = []; - foreach ($differingKeys as $exprString => $unusedDiffMarker) { + foreach (array_keys($differingKeys) as $exprString) { if (!array_key_exists($exprString, $theirExpressionTypes)) { continue; } @@ -399,7 +389,7 @@ public static function createConditionalExpressions( } $typeGuards = []; - foreach ($differingKeys as $exprString => $unusedDiffMarker) { + foreach (array_keys($differingKeys) as $exprString) { if (!array_key_exists($exprString, $newVariableTypes)) { continue; } @@ -443,7 +433,7 @@ public static function createConditionalExpressions( $guardIsSuperTypeOfTheirExprCache = []; $theirExprIsSuperTypeOfGuardCache = []; - foreach ($differingKeys as $exprString => $unusedDiffMarker) { + foreach (array_keys($differingKeys) as $exprString) { if (!array_key_exists($exprString, $newVariableTypes)) { continue; } @@ -508,7 +498,7 @@ public static function createConditionalExpressions( } } - foreach ($differingKeys as $exprString => $unusedDiffMarker) { + foreach (array_keys($differingKeys) as $exprString) { if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } @@ -526,130 +516,6 @@ public static function createConditionalExpressions( return $conditionalExpressions; } - /** - * Depth-first pre-order search for the invalidated expression, replacing a - * NodeFinder::findFirst() call - this runs for every (stored expression, - * invalidated expression) pair whose keys pass the substring pre-filter, - * so the traverser/visitor machinery overhead was significant. - * - * @param class-string $expressionToInvalidateClass - */ - private static function containsExpressionToInvalidate(Scope $scope, ExprPrinter $exprPrinter, Node $node, string $expressionToInvalidateClass, string $exprStringToInvalidate): bool - { - if ( - $exprStringToInvalidate === '$this' - && $node instanceof Name - && ( - in_array($node->toLowerString(), ['self', 'static', 'parent'], true) - || ($scope->getClassReflection() !== null && $scope->getClassReflection()->is($scope->resolveName($node))) - ) - ) { - return true; - } - - if ( - $node instanceof $expressionToInvalidateClass - && self::nodeKey($node, $exprPrinter) === $exprStringToInvalidate - ) { - return true; - } - - foreach ($node->getSubNodeNames() as $subNodeName) { - $subNode = $node->$subNodeName; - if ($subNode instanceof Node) { - if (self::containsExpressionToInvalidate($scope, $exprPrinter, $subNode, $expressionToInvalidateClass, $exprStringToInvalidate)) { - return true; - } - } elseif (is_array($subNode)) { - foreach ($subNode as $subNodeItem) { - if ( - $subNodeItem instanceof Node - && self::containsExpressionToInvalidate($scope, $exprPrinter, $subNodeItem, $expressionToInvalidateClass, $exprStringToInvalidate) - ) { - return true; - } - } - } - } - - return false; - } - - /** - * The scan of MutatingScope::invalidateExpression(): computes the tables - * with the invalidated entries removed, or null when nothing changed. - * - * @param array $expressionTypes - * @param array $nativeExpressionTypes - * @param array $conditionalExpressions - * @return array{array, array, array}|null - */ - public static function invalidateExpressionEntries( - MutatingScope $scope, - ExprPrinter $exprPrinter, - string $exprStringToInvalidate, - Expr $expressionToInvalidate, - bool $requireMoreCharacters, - ?ClassReflection $invalidatingClass, - array $expressionTypes, - array $nativeExpressionTypes, - array $conditionalExpressions, - ): ?array - { - $invalidated = false; - - foreach ($expressionTypes as $exprString => $exprTypeHolder) { - $exprExpr = $exprTypeHolder->getExpr(); - if (!self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $exprExpr, (string) $exprString, $requireMoreCharacters, $invalidatingClass)) { - continue; - } - - unset($expressionTypes[$exprString]); - unset($nativeExpressionTypes[$exprString]); - $invalidated = true; - } - - $newConditionalExpressions = []; - foreach ($conditionalExpressions as $conditionalExprString => $holders) { - if (count($holders) === 0) { - continue; - } - $firstExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); - if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $firstExpr, self::nodeKey($firstExpr, $exprPrinter), $requireMoreCharacters, $invalidatingClass)) { - $invalidated = true; - continue; - } - $filteredHolders = []; - foreach ($holders as $key => $holder) { - $shouldKeep = true; - $conditionalTypeHolders = $holder->getConditionExpressionTypeHolders(); - foreach ($conditionalTypeHolders as $conditionalTypeHolderExprString => $conditionalTypeHolder) { - if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr(), (string) $conditionalTypeHolderExprString, invalidatingClass: $invalidatingClass)) { - $invalidated = true; - $shouldKeep = false; - break; - } - } - if (!$shouldKeep) { - continue; - } - - $filteredHolders[$key] = $holder; - } - if (count($filteredHolders) <= 0) { - continue; - } - - $newConditionalExpressions[$conditionalExprString] = $filteredHolders; - } - - if (!$invalidated) { - return null; - } - - return [$expressionTypes, $nativeExpressionTypes, $newConditionalExpressions]; - } - /** * The scan of MutatingScope::invalidateMethodsOnExpression(): drops tracked * MethodCall expressions whose var matches the invalidated key, or returns @@ -689,76 +555,6 @@ public static function invalidateMethodsOnExpression( return [$expressionTypes, $nativeExpressionTypes]; } - /** - * Mirrors the former MutatingScope::shouldInvalidateExpression(). - */ - public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrinter $exprPrinter, string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool - { - if ( - $expr instanceof IntertwinedVariableByReferenceWithExpr - && $exprToInvalidate instanceof Variable - && is_string($exprToInvalidate->name) - && ( - $expr->getVariableName() === $exprToInvalidate->name - || self::getIntertwinedRefRootVariableName($expr->getExpr()) === $exprToInvalidate->name - || self::getIntertwinedRefRootVariableName($expr->getAssignedExpr()) === $exprToInvalidate->name - ) - ) { - return false; - } - - if ($requireMoreCharacters && $exprStringToInvalidate === $exprString) { - return false; - } - - // Variables will not contain traversable expressions. skip the NodeFinder overhead - if ($expr instanceof Variable && is_string($expr->name) && !$requireMoreCharacters) { - return $exprStringToInvalidate === $exprString; - } - - // getNodeKey() is the pretty-printed expression, and the standard printer is - // compositional: the key of any sub-expression appears verbatim as a substring of - // the key of the expression containing it. So if the invalidated expression's key - // does not appear anywhere in this expression's key, this expression cannot contain - // it and we can skip the expensive AST traversal below. - // Carve-outs where that invariant does not hold: - // - '$this' is special-cased in the visitor to also match self/static/parent, - // - PHPStan's virtual nodes (printed as '__phpstan…') use non-compositional printers - // (e.g. a wrapped variable is printed by name, not as '$name'), - // - keys carrying a getNodeKey() suffix ('/*…*/') are not plain substrings. - if ( - $exprStringToInvalidate !== '$this' - && !str_contains($exprStringToInvalidate, '__phpstan') - && !str_contains($exprStringToInvalidate, '/*') - && !str_contains($exprString, '__phpstan') - && !str_contains($exprString, $exprStringToInvalidate) - ) { - return false; - } - - if (!self::containsExpressionToInvalidate($scope, $exprPrinter, $expr, get_class($exprToInvalidate), $exprStringToInvalidate)) { - return false; - } - - if ( - $expr instanceof PropertyFetch - && $requireMoreCharacters - && $scope->isReadonlyPropertyFetch($expr, false) - ) { - return false; - } - - if ( - $invalidatingClass !== null - && $requireMoreCharacters - && $scope->isPrivatePropertyOfDifferentClass($expr, $invalidatingClass) - ) { - return false; - } - - return true; - } - public static function getIntertwinedRefRootVariableName(Expr $expr): ?string { if ($expr instanceof Variable && is_string($expr->name)) { @@ -770,51 +566,6 @@ public static function getIntertwinedRefRootVariableName(Expr $expr): ?string return null; } - /** - * The sorted type-specification list of MutatingScope::filterBySpecifiedTypes(). - * - * @param array $sureTypes - * @param array $sureNotTypes - * @return list - */ - public static function buildTypeSpecifications(array $sureTypes, array $sureNotTypes): array - { - $typeSpecifications = []; - foreach ($sureTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => true, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - foreach ($sureNotTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => false, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - - usort($typeSpecifications, static function (array $a, array $b): int { - $length = strlen($a['exprString']) - strlen($b['exprString']); - if ($length !== 0) { - return $length; - } - - return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric - }); - - return $typeSpecifications; - } - /** * The conditional-expressions fixed-point matching of * MutatingScope::filterBySpecifiedTypes().