Repository navigation
Pass only the right-hand side of ??= as the written expression to PropertyAssignNode and VariableAssignNode - #6943
phpstan-bot wants to merge 3 commits into
Conversation
…PropertyAssignNode` and `VariableAssignNode` - `$lvalue ??= value` writes only `value`, but AssignHandler handed the whole `AssignOp\Coalesce` node to PropertyAssignNode/VariableAssignNode, whose type is `nonNull($lvalue)|value`. The rules checking the write therefore saw a widened type. - Add AssignHandler::getWrittenExpr() and use it for every PropertyAssignNode (instance + static property, resolved + fallback branches), for the Set*OffsetValueTypeExpr chain built for offset writes on properties (ArrayDimFetch and ExistingArrayDimFetch), and for the VariableAssignNode of plain variable targets. Scope updates still use the full `??=` type. - Mirror the change in turbo-ext/src/AssignHandler.cpp and regenerate turbo-ext/src/generated/AssignHandler.h. - Fixes in TypesAssignedToPropertiesRule: false negative below level 7 (`?int` ??= 'boom' not reported) and false positive at level 7+ (stale narrowed property type merged into the reported type), for instance properties, static properties and property offsets. - Same root cause fixed for ParameterOutAssignedTypeRule (`@param-out` checks on `$p ??= ...`) and TooWidePropertyTypeRule (the kept old value hid "never assigned X" reports).
| if ($assignedExpr instanceof Expr\AssignOp\Coalesce) { | ||
| return $assignedExpr->expr; | ||
| } |
There was a problem hiding this comment.
do we have the same problem with other assignment operators?
There was a problem hiding this comment.
No, the other assignment operators don't have this problem, so the code stays as it is. I added a test to show it and pushed it as cded8d547.
Why only ??= is affected: for $x OP= $v with any other operator, the value of the whole expression is exactly what gets written to $x. Passing that expression to PropertyAssignNode/VariableAssignNode is already correct. ??= is the only one where the two differ: the expression's type, nonNull($x) | $v, includes the old value, which is kept and never written.
Test: a new OtherAssignOpsDemo class in tests/PHPStan/Rules/Properties/data/bug-15228.php puts each compound assignment next to its expanded form:
$this->a .= 'x'and$this->a = $this->a . 'x'both report "(int|null)does not accept string".$this->r += 5and$this->r = $this->r + 5both report "(int<0, 10>)does not acceptint<5, 15>".
The errors match with checkUnionTypes on and off. With it off, the += pair isn't reported in either form, so they still agree. make tests and make phpstan both pass.
One unrelated thing came up while checking: $this->i /= $x on an int property, which gives int|float, isn't reported. The expanded $this->i = $this->i / $x isn't reported either, so it isn't specific to compound assignment and is outside this PR.
…their expanded form Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
in #6860 there is a competing PR which also adjusts AssignOpHandler |
VincentLanglet
left a comment
There was a problem hiding this comment.
Does other Handler need a similar fix ? Like AssignOpHandler ?
…tRule Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Rules that also listen to
|
Summary
$lvalue ??= $valuewas checked as if the whole expression's type,nonNull($lvalue) | $value, was written to the target. Only$valueis ever written, and only when the target is null. Below level 7 this hid real errors:?int??='boom'sawint|string, which is a maybe accept. At level 7+ it caused false positives: a stale narrowed type of the property leaked into the checked type. The assign nodes now get only the right-hand side, the same as for the equivalentif (null === $lvalue) { $lvalue = $value; }.Changes
src/Analyser/ExprHandler/AssignHandler.php: new privategetWrittenExpr()unwrapsAssignOp\Coalesceto its right-hand side. It is used for:PropertyAssignNodefor instance and static property fetches, in both the resolved and the fallback branchesSetOffsetValueTypeExpr/SetExistingOffsetValueTypeExprchain built for offset writes ($this->prop['k'] ??= ...), for bothArrayDimFetchandExistingArrayDimFetchtargetsVariableAssignNodefor plain variable targetsScope updates still use the full
??=result type.turbo-ext/src/AssignHandler.cpp: the same change in the native mirror (getWrittenExpr()plus the same call sites).turbo-ext/src/generated/AssignHandler.his regenerated because it lists private methods too.Root cause
Every
AssignOpgoes through the same read-modify-write path. That path passes the whole$lvalue OP= valueexpression as the "assigned expression" to the virtual assign nodes. For+=,.=and the others, the expression's value is exactly what gets written.??=is the oneAssignOpwhere the two differ. The over-wide type reached every consumer of these nodes:TypesAssignedToPropertiesRule: the reported bug, for instance properties, static properties and property offsetsParameterOutAssignedTypeRule:@param-outchecks on$p ??= ..., with the same false negative and false positiveTooWideTypeCheck(TooWidePropertyTypeRule): the kept old value counted as an assigned type, so "never assigned X" was not reportedTest
tests/PHPStan/Rules/Properties/data/bug-15228.php: the issue's reproducer, plus static-property and property-offset variants. It runs withcheckUnionTypesboth on and off (TypesAssignedToPropertiesRuleTest).tests/PHPStan/Rules/Variables/data/bug-15228.php: the@param-outanalog (ParameterOutAssignedTypeRuleTest).tests/PHPStan/Rules/TooWideTypehints/data/bug-15228.php: the too-wide-property analog (TooWidePropertyTypeRuleTest).All of these fail without the fix and pass with it. The full test suite passes with and without the turbo extension loaded, and analysis output is identical in both modes.
Fixes phpstan/phpstan#15228
🤖 Generated with Claude Code