Skip to content

Check only the right side of ??= as the value written to its target - #6860

Open
ChrisAbner wants to merge 1 commit into
phpstan:2.3.xfrom
ChrisAbner:coalesce-assign-written-value
Open

ChrisAbner wants to merge 1 commit into
phpstan:2.3.xfrom
ChrisAbner:coalesce-assign-written-value

Conversation

@ChrisAbner

Copy link
Copy Markdown

Closes phpstan/phpstan#15228

AssignOpHandler resolves $x ??= $v as $x ?? $v. That is right for the value of the expression and for the type $x has afterwards, but AssignHandler handed the same expression to the nodes that rules use to check what gets written: PropertyAssignNode, VariableAssignNode and the innermost value of the Set*OffsetValueTypeExpr chain. So every rule saw nonNull($x)|$v instead of $v, the only thing ??= ever writes. That caused the false negative and false positive in the issue (TypesAssignedToPropertiesRule), and the same bug in static properties, offset writes ($this->m['k'] ??= ...), ParameterOutAssignedTypeRule and TooWidePropertyTypeRule. AssignHandler::getWrittenExpr() now passes $v to those nodes when the target is the left side of the ??= itself, so $y = ($x ??= $v) is unchanged, and the scope update still uses $x ?? $v.

That alone would hide one bug that the whole-expression type had been catching by accident. With @param-out string on int|string|null &$p, $p ??= 'x' leaves an int in $p. ParameterOutExecutionEndTypeRule is the rule that should report it, but it skips a parameter once ParameterVariableOriginalValueExpr is gone, and the write removed it as if it always happened. AssignOpHandler now keeps it with Maybe certainty when the left side may be non-null, which is what merging the two branches of the equivalent if gives.

I first fixed only TypesAssignedToPropertiesRule, but the same mix-up showed up in three more consumers, so the fix belongs where the written expression is chosen. I also tried modelling ??= fully as the if it stands for, merging the written and not-written scopes. That broke bug-15498, bug-optionalArrayKeyInMultiDimArrayLoop and MissingReadOnlyPropertyAssignRuleTest: in those cases the current ??= state is more precise than the if form. Losing other facts on ??= only costs precision; the original-value marker is the one whose absence turns a check off. Both handlers have turbo mirrors, so the change is ported to AssignHandler.cpp and AssignOpHandler.cpp. generated/AssignHandler.h is regenerated with PHP 8.5: on PHP 8.4 the generator also rewrites 56 unrelated headers.

Verification. bin/phpstan analyse from this branch, before (2.3.x at 49861bec) and after. The "after" column is the same at levels 6 and 7, and the same with the turbo extension loaded:

Code Before, level 6 Before, level 7 After
$this->a ??= 'boom'; (?int) no error does not accept int|string does not accept string
$this->b ??= [1, 2]; after $this->b = strings(); (list<int>|null) no error does not accept list<1|2|string> no error
self::$s ??= 'boom'; (static ?int) no error does not accept int|string does not accept string
$this->m['k'] ??= 'boom'; (array<string, int>) no error does not accept array<string, int|string> does not accept array<string, int|string>
private int|string|null $x only written by $this->x ??= 1; no error no error never assigned string
@param-out int, ?int &$p, $p ??= 'boom'; expects int, int|string given same expects int, string given on the write, int|string at exit
@param-out list<int>|null, $p = strings(); $p ??= [1, 2]; list<string> given on the ??= line same no error on the ??= line
@param-out string, int|string|null &$p, $p ??= 'x'; expects string, int|string given same same, now reported by the exit check

Each "after" cell matches what the if (null === …) form reports. The new tests (TypesAssignedToPropertiesRuleTest ×2, ParameterOutAssignedTypeRuleTest, ParameterOutExecutionEndTypeRuleTest, TooWidePropertyTypeRuleTest) fail on 2.3.x and pass with the change. The exit-check test also fails if only the AssignOpHandler part is reverted. With and without the extension loaded, these pass: tests/PHPStan/Rules (3618 tests), NodeScopeResolverTest (1888), the rest of tests/PHPStan/Analyser (1168), tests/PHPStan/Type (6531), tests/PHPStan/Reflection and the remaining directories. For the extension: strict build without warnings, smoke.php ALL OK, side-by-side.php and signature-parity.php OK, make sanitize OK (smoke, arena-smoke, signature-parity, parser-corpus under UBSan), and clang-tidy 21 clean on the two changed files. Analysis output is byte-identical with and without the extension over the 55 test data files and the 105 files of src/ that use ??=. phpcs and PHPStan with the repository config are clean on the changed PHP files.

Not covered: I did not run the levels and exec groups or the full make lint-turbo locally, and I did not benchmark the change. $p['k'] ??= 'x' on a by-ref parameter is reported once, on the write (whose value is the whole array), where the if form also reports it again at function exit.

`$x ??= $v` evaluates to `$x ?? $v`, but only ever writes `$v`. The nodes
rules use to check the write (PropertyAssignNode, VariableAssignNode and the
innermost value of the Set*OffsetValueTypeExpr chain) now carry `$v` when the
target is the left side of the `??=`, while the scope update keeps `$x ?? $v`.

The write is also conditional, so a by-ref parameter keeps its original-value
marker with Maybe certainty after `$p ??= ...` when the left side may be
non-null, and ParameterOutExecutionEndTypeRule still checks it at exit.

Ported to the turbo-ext mirrors of AssignHandler and AssignOpHandler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive and false negative with null coalescing assignment operator (??=)

1 participant