From 2b26cdf84afac94921f334a4f6d8dfa801b9ecba Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 17 Jun 2026 16:53:08 +0200 Subject: [PATCH] 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/ExprHandler/VariableHandler.php | 4 ++-- src/Analyser/ExpressionResult.php | 12 ++++++++++++ src/Analyser/ExpressionResultFactory.php | 1 + 10 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index eb89d6c618b..b52587be917 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 f5704cccdb5..dba59689474 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -211,6 +211,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 524cad05a6d..d7dc61b42e4 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 f2d843625b0..691c19d3910 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 8231d9da28b..805b2b190d5 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -94,6 +94,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 280adc40833..b92881a1863 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -88,6 +88,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(); @@ -96,6 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); + $containsNullsafe = $classResult->containsNullsafe(); } $parametersAcceptor = null; @@ -299,6 +301,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 a1b2f4d29b9..dd15b5a6eb4 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/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 6082dea55f2..eff0cee6c9a 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -103,8 +103,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating, $throwPoints, $impurePoints, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), + falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7669a67a2c8..91a801351b7 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -34,6 +34,7 @@ public function __construct( private bool $isAlwaysTerminating, private array $throwPoints, private array $impurePoints, + private bool $containsNullsafe = false, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ) @@ -57,6 +58,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 255d6258fba..8044067a87f 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -21,6 +21,7 @@ public function create( bool $isAlwaysTerminating, array $throwPoints, array $impurePoints, + bool $containsNullsafe = false, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, ): ExpressionResult;