1313use PhpParser \Node \Expr \FuncCall ;
1414use PhpParser \Node \Expr \List_ ;
1515use PhpParser \Node \Expr \MethodCall ;
16+ use PhpParser \Node \Expr \NullsafeMethodCall ;
1617use PhpParser \Node \Expr \PropertyFetch ;
18+ use PhpParser \Node \Expr \StaticCall ;
1719use PhpParser \Node \Expr \StaticPropertyFetch ;
1820use PhpParser \Node \Expr \Ternary ;
1921use PhpParser \Node \Expr \Variable ;
4446use PHPStan \Node \Expr \TypeExpr ;
4547use PHPStan \Node \PropertyAssignNode ;
4648use PHPStan \Node \VariableAssignNode ;
49+ use PHPStan \Node \VirtualNode ;
4750use PHPStan \Php \PhpVersion ;
4851use PHPStan \ShouldNotHappenException ;
4952use PHPStan \TrinaryLogic ;
@@ -259,23 +262,23 @@ public function processAssignVar(
259262 $ truthyType ->isSuperTypeOf ($ falseyType )->no ()
260263 && $ falseyType ->isSuperTypeOf ($ truthyType )->no ()
261264 ) {
262- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType );
263- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType );
264- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType );
265- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType );
265+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType, $ impurePoints , $ assignedExpr );
266+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType, $ impurePoints , $ assignedExpr );
267+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
268+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ condScope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
266269 }
267270 }
268271
269272 $ truthyType = TypeCombinator::removeFalsey ($ type );
270273 if ($ truthyType !== $ type ) {
271274 $ truthySpecifiedTypes = $ this ->typeSpecifier ->specifyTypesInCondition ($ scope , $ assignedExpr , TypeSpecifierContext::createTruthy ());
272- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType );
273- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType );
275+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType, $ impurePoints , $ assignedExpr );
276+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ truthySpecifiedTypes , $ truthyType, $ impurePoints , $ assignedExpr );
274277
275278 $ falseyType = TypeCombinator::intersect ($ type , StaticTypeFactory::falsey ());
276279 $ falseySpecifiedTypes = $ this ->typeSpecifier ->specifyTypesInCondition ($ scope , $ assignedExpr , TypeSpecifierContext::createFalsey ());
277- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType );
278- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType );
280+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
281+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ falseySpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
279282 }
280283
281284 foreach ([null , false , 0 , 0.0 , '' , '0 ' , []] as $ falseyScalar ) {
@@ -304,13 +307,13 @@ public function processAssignVar(
304307
305308 $ notIdenticalConditionExpr = new Expr \BinaryOp \NotIdentical ($ assignedExpr , $ astNode );
306309 $ notIdenticalSpecifiedTypes = $ this ->typeSpecifier ->specifyTypesInCondition ($ scope , $ notIdenticalConditionExpr , TypeSpecifierContext::createTrue ());
307- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ notIdenticalSpecifiedTypes , $ withoutFalseyType );
308- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ notIdenticalSpecifiedTypes , $ withoutFalseyType );
310+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ notIdenticalSpecifiedTypes , $ withoutFalseyType, $ impurePoints , $ assignedExpr );
311+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ notIdenticalSpecifiedTypes , $ withoutFalseyType, $ impurePoints , $ assignedExpr );
309312
310313 $ identicalConditionExpr = new Expr \BinaryOp \Identical ($ assignedExpr , $ astNode );
311314 $ identicalSpecifiedTypes = $ this ->typeSpecifier ->specifyTypesInCondition ($ scope , $ identicalConditionExpr , TypeSpecifierContext::createTrue ());
312- $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ identicalSpecifiedTypes , $ falseyType );
313- $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ identicalSpecifiedTypes , $ falseyType );
315+ $ conditionalExpressions = $ this ->processSureTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ identicalSpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
316+ $ conditionalExpressions = $ this ->processSureNotTypesForConditionalExpressionsAfterAssign ($ scope , $ var ->name , $ conditionalExpressions , $ identicalSpecifiedTypes , $ falseyType, $ impurePoints , $ assignedExpr );
314317 }
315318
316319 $ nodeScopeResolver ->callNodeCallback ($ nodeCallback , new VariableAssignNode ($ var , $ assignedExpr ), $ scopeBeforeAssignEval , $ storage );
@@ -848,24 +851,13 @@ private function unwrapAssign(Expr $expr): Expr
848851
849852 /**
850853 * @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
854+ * @param ImpurePoint[] $rhsImpurePoints
851855 * @return array<string, ConditionalExpressionHolder[]>
852856 */
853- private function processSureTypesForConditionalExpressionsAfterAssign (Scope $ scope , string $ variableName , array $ conditionalExpressions , SpecifiedTypes $ specifiedTypes , Type $ variableType ): array
857+ private function processSureTypesForConditionalExpressionsAfterAssign (Scope $ scope , string $ variableName , array $ conditionalExpressions , SpecifiedTypes $ specifiedTypes , Type $ variableType, array $ rhsImpurePoints , Expr $ assignedExpr ): array
854858 {
855859 foreach ($ specifiedTypes ->getSureTypes () as $ exprString => [$ expr , $ exprType ]) {
856- if ($ expr instanceof Variable) {
857- if (!is_string ($ expr ->name )) {
858- continue ;
859- }
860-
861- if ($ expr ->name === $ variableName ) {
862- continue ;
863- }
864- } elseif (
865- !$ expr instanceof PropertyFetch
866- && !$ expr instanceof ArrayDimFetch
867- && !$ expr instanceof FuncCall
868- ) {
860+ if (!$ this ->isExprSafeToProjectThroughVariable ($ expr , $ variableName , $ rhsImpurePoints , $ assignedExpr )) {
869861 continue ;
870862 }
871863
@@ -887,24 +879,13 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco
887879
888880 /**
889881 * @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
882+ * @param ImpurePoint[] $rhsImpurePoints
890883 * @return array<string, ConditionalExpressionHolder[]>
891884 */
892- private function processSureNotTypesForConditionalExpressionsAfterAssign (Scope $ scope , string $ variableName , array $ conditionalExpressions , SpecifiedTypes $ specifiedTypes , Type $ variableType ): array
885+ private function processSureNotTypesForConditionalExpressionsAfterAssign (Scope $ scope , string $ variableName , array $ conditionalExpressions , SpecifiedTypes $ specifiedTypes , Type $ variableType, array $ rhsImpurePoints , Expr $ assignedExpr ): array
893886 {
894887 foreach ($ specifiedTypes ->getSureNotTypes () as $ exprString => [$ expr , $ exprType ]) {
895- if ($ expr instanceof Variable) {
896- if (!is_string ($ expr ->name )) {
897- continue ;
898- }
899-
900- if ($ expr ->name === $ variableName ) {
901- continue ;
902- }
903- } elseif (
904- !$ expr instanceof PropertyFetch
905- && !$ expr instanceof ArrayDimFetch
906- && !$ expr instanceof FuncCall
907- ) {
888+ if (!$ this ->isExprSafeToProjectThroughVariable ($ expr , $ variableName , $ rhsImpurePoints , $ assignedExpr )) {
908889 continue ;
909890 }
910891
@@ -924,6 +905,76 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $
924905 return $ conditionalExpressions ;
925906 }
926907
908+ /**
909+ * We're about to remember "when $variableName is truthy/falsy, $expr has a narrower type".
910+ * Whether that's safe to project forward depends on whether re-evaluating $expr later will
911+ * still return the same value as when we observed the narrowing — i.e. whether $expr is
912+ * referentially transparent with respect to the intervening code.
913+ *
914+ * Scalar/const-fetch literals are never narrowing targets, so skip them up front (they also
915+ * happen to stringify to numeric exprStrings which collide with PHP's numeric-string
916+ * array-key autocast).
917+ *
918+ * A plain variable is always safe: reading it doesn't produce side effects, and if it gets
919+ * reassigned the existing conditional-expression-holder machinery invalidates the binding.
920+ * This case matters for e.g. `$ok = preg_match(..., $matches); if ($ok) { use $matches }` —
921+ * `preg_match` itself has impure points, but `$matches` is a plain variable and the
922+ * narrowing attached to it should still survive.
923+ *
924+ * Other common tracked expressions (property/dim fetches, function/method calls) can always
925+ * carry narrowings: PHPStan already memoises their types per exprString, and condition
926+ * checks like `$x = $obj->foo() !== null; if ($x) { $obj->foo(); }` rely on this even when
927+ * the RHS itself has impure points (as a method call without @phpstan-pure always does).
928+ *
929+ * Anything else is accepted only when the right-hand side evaluation recorded zero impure
930+ * points — in that case all sub-expressions it produced sure types for were evaluated
931+ * without side effects and can be re-evaluated later with the same result.
932+ *
933+ * @param ImpurePoint[] $rhsImpurePoints
934+ */
935+ private function isExprSafeToProjectThroughVariable (Expr $ expr , string $ variableName , array $ rhsImpurePoints , Expr $ assignedExpr ): bool
936+ {
937+ // Scalar/const-fetch literals and PHPStan virtual nodes (e.g. NativeTypeExpr) are never
938+ // narrowing targets at a usage site — skip them so they don't collide with PHP's
939+ // numeric-string array-key autocast or leak internal virtual expressions into the
940+ // conditional-expression map.
941+ if ($ expr instanceof Node \Scalar || $ expr instanceof ConstFetch || $ expr instanceof VirtualNode) {
942+ return false ;
943+ }
944+
945+ if ($ expr instanceof Variable) {
946+ return is_string ($ expr ->name ) && $ expr ->name !== $ variableName ;
947+ }
948+
949+ if (
950+ $ expr instanceof PropertyFetch
951+ || $ expr instanceof ArrayDimFetch
952+ ) {
953+ return true ;
954+ }
955+
956+ if (
957+ $ expr instanceof FuncCall
958+ || $ expr instanceof MethodCall
959+ || $ expr instanceof NullsafeMethodCall
960+ || $ expr instanceof StaticCall
961+ ) {
962+ // A call's type can change between evaluations. We're willing to project the
963+ // narrowing through a stored boolean only when the sure-type expression is a
964+ // *sub*-expression of the assigned RHS — e.g. `$ok = $x->foo() !== null` builds
965+ // a sure type for the sub-call `$x->foo()`. In that case the RHS as a whole
966+ // carries the comparison result, and later `if ($ok)` usefully re-narrows the
967+ // remembered sub-call. When the sure-type expression IS the whole RHS (e.g.
968+ // `$device = $this->nullable(); if ($device === null) { … }` with the
969+ // falsey-scalar loop producing `$this->nullable() === null` narrowings), the
970+ // projection would survive across subsequent reassignments of the target
971+ // expression and wrongly re-narrow fresh calls — so skip it.
972+ return $ expr !== $ assignedExpr ;
973+ }
974+
975+ return count ($ rhsImpurePoints ) === 0 ;
976+ }
977+
927978 /**
928979 * @param list<ArrayDimFetch> $dimFetchStack
929980 */
0 commit comments