6868use PHPStan \Node \VariableAssignNode ;
6969use PHPStan \Node \VirtualNode ;
7070use PHPStan \Php \PhpVersion ;
71+ use PHPStan \Reflection \InitializerExprTypeResolver ;
7172use PHPStan \Rules \Properties \PropertyReflectionFinder ;
7273use PHPStan \ShouldNotHappenException ;
7374use PHPStan \TrinaryLogic ;
107108use function is_nan ;
108109use function is_string ;
109110use function spl_object_id ;
111+ use function str_contains ;
110112
111113/**
112114 * @implements ExprHandler<Assign|AssignRef>
@@ -138,6 +140,7 @@ public function __construct(
138140 private StaticPropertyFetchHandler $ staticPropertyFetchHandler ,
139141 private MethodThrowPointHelper $ methodThrowPointHelper ,
140142 private PropertyHookThrowPointsResolver $ propertyHookThrowPointsResolver ,
143+ private InitializerExprTypeResolver $ initializerExprTypeResolver ,
141144 )
142145 {
143146 }
@@ -1296,7 +1299,23 @@ public function applyWrite(
12961299 }
12971300
12981301 $ nodeScopeResolver ->callNodeCallback ($ nodeCallback , new VariableAssignNode ($ var , $ assignedExpr ), $ scopeBeforeAssignEval , $ storage );
1299-
1302+ $ remappedConditionalExpressions = [];
1303+ if (
1304+ // only the concat-assign's own write remaps - an enclosing plain
1305+ // assignment of the same variable (`$s = $s .= 'x'`) sees the
1306+ // already-remapped holders and would remap them a second time
1307+ $ isAssignOp
1308+ && $ assignedExpr instanceof AssignOp \Concat
1309+ && $ assignedExpr ->var instanceof Variable
1310+ && $ assignedExpr ->var ->name === $ var ->name
1311+ && $ scope ->getConditionalExpressions () !== []
1312+ ) {
1313+ $ remappedConditionalExpressions = $ this ->remapConditionalExpressionsThroughConcatAssign (
1314+ $ scope ->getConditionalExpressions (),
1315+ '$ ' . $ var ->name ,
1316+ $ valueResult ->getType (),
1317+ );
1318+ }
13001319 $ nativeType = $ this ->readAssignedValueType ($ nodeScopeResolver , $ storedAssignedExprResult , $ assignedExpr , $ scope ->doNotTreatPhpDocTypesAsCertain ());
13011320 $ scope = $ scope ->assignVariable (
13021321 $ var ->name ,
@@ -1305,6 +1324,9 @@ public function applyWrite(
13051324 TrinaryLogic ::createYes (),
13061325 [],
13071326 );
1327+ foreach ($ remappedConditionalExpressions as $ exprString => $ holders ) {
1328+ $ scope = $ scope ->addConditionalExpressions ((string ) $ exprString , $ holders ); // @phpstan-ignore cast.useless
1329+ }
13081330 foreach ($ conditionalExpressions as $ exprString => $ holders ) {
13091331 $ scope = $ scope ->addConditionalExpressions ((string ) $ exprString , $ holders );
13101332 }
@@ -2014,6 +2036,100 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeSco
20142036 return $ conditionalExpressions ;
20152037 }
20162038
2039+ /**
2040+ * `$var .= <single constant string>` keeps the conditional-expression
2041+ * holders about $var alive by remapping them through the append instead of
2042+ * losing them to the write's invalidation. A consequence type about $var is
2043+ * concatenated with the appended constant; a condition on $var is remapped
2044+ * only when it is itself a single constant string - appending a fixed
2045+ * suffix is injective, so the remapped condition selects exactly the states
2046+ * the original condition did. Holders mentioning $var inside a composite
2047+ * expression, with a non-Yes certainty about $var, or with a non-constant
2048+ * condition on $var are left to the regular invalidation. This keeps e.g. a
2049+ * format string built across `if (!empty($target))` correlated with
2050+ * $target while further pieces are appended.
2051+ *
2052+ * @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
2053+ * @return array<string, ConditionalExpressionHolder[]>
2054+ */
2055+ private function remapConditionalExpressionsThroughConcatAssign (
2056+ array $ conditionalExpressions ,
2057+ string $ varExprString ,
2058+ Type $ appendedType ,
2059+ ): array
2060+ {
2061+ $ appendedConstantStrings = $ appendedType ->getConstantStrings ();
2062+ if (count ($ appendedConstantStrings ) !== 1 || !$ appendedType ->equals ($ appendedConstantStrings [0 ])) {
2063+ return [];
2064+ }
2065+ $ appendedConstantString = $ appendedConstantStrings [0 ];
2066+
2067+ $ remapped = [];
2068+ foreach ($ conditionalExpressions as $ targetExprString => $ holders ) {
2069+ $ targetExprString = (string ) $ targetExprString ; // @phpstan-ignore cast.useless
2070+ $ targetIsVar = $ targetExprString === $ varExprString ;
2071+ if (!$ targetIsVar && str_contains ($ targetExprString , $ varExprString )) {
2072+ // a composite target containing the variable ($var[0], f($var), ...)
2073+ continue ;
2074+ }
2075+
2076+ foreach ($ holders as $ holder ) {
2077+ $ holderTouchesVar = $ targetIsVar ;
2078+ $ remappable = true ;
2079+ $ newConditions = [];
2080+ foreach ($ holder ->getConditionExpressionTypeHolders () as $ conditionExprString => $ conditionHolder ) {
2081+ $ conditionExprString = (string ) $ conditionExprString ; // @phpstan-ignore cast.useless
2082+ if ($ conditionExprString === $ varExprString ) {
2083+ $ holderTouchesVar = true ;
2084+ $ conditionConstantStrings = $ conditionHolder ->getType ()->getConstantStrings ();
2085+ if (
2086+ !$ conditionHolder ->getCertainty ()->yes ()
2087+ || count ($ conditionConstantStrings ) !== 1
2088+ || !$ conditionHolder ->getType ()->equals ($ conditionConstantStrings [0 ])
2089+ ) {
2090+ $ remappable = false ;
2091+ break ;
2092+ }
2093+ $ newConditions [$ conditionExprString ] = ExpressionTypeHolder ::createYes (
2094+ $ conditionHolder ->getExpr (),
2095+ $ conditionConstantStrings [0 ]->append ($ appendedConstantString ),
2096+ );
2097+ continue ;
2098+ }
2099+
2100+ if (str_contains ($ conditionExprString , $ varExprString )) {
2101+ $ remappable = false ;
2102+ break ;
2103+ }
2104+
2105+ $ newConditions [$ conditionExprString ] = $ conditionHolder ;
2106+ }
2107+ if (!$ remappable || !$ holderTouchesVar ) {
2108+ continue ;
2109+ }
2110+
2111+ $ typeHolder = $ holder ->getTypeHolder ();
2112+ if ($ targetIsVar ) {
2113+ if (!$ typeHolder ->getCertainty ()->yes ()) {
2114+ // the append leaves the variable defined on every path -
2115+ // an undefined/maybe consequence cannot be carried over
2116+ continue ;
2117+ }
2118+ $ concatType = $ this ->initializerExprTypeResolver ->resolveConcatType ($ typeHolder ->getType (), $ appendedType );
2119+ if ($ concatType instanceof ErrorType) {
2120+ continue ;
2121+ }
2122+ $ typeHolder = ExpressionTypeHolder ::createYes ($ typeHolder ->getExpr (), $ concatType );
2123+ }
2124+
2125+ $ newHolder = new ConditionalExpressionHolder ($ newConditions , $ typeHolder );
2126+ $ remapped [$ targetExprString ][$ newHolder ->getKey ()] = $ newHolder ;
2127+ }
2128+ }
2129+
2130+ return $ remapped ;
2131+ }
2132+
20172133 /**
20182134 * Records "if the assigned variable has $variableType, the target variable is
20192135 * certainly defined (with its branch-scope type)" holders for variables whose
0 commit comments