Do not report ?? null / ??= null as unnecessary when evaluating the right side has side effects - #6418
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
1) PHPStan\Rules\Variables\NullCoalesceRuleTest::testUnnecessaryNullCoalesceSideEffects
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
'104: Coalesce operator ??= is unnecessary because the left side is always set and the right side is null.
-111: Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.
116: Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.
121: Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.
128: Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.
133: Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.
'
on PHP 7.4
|
Fixed and pushed as Cause of the PHP 7.4 failure: the data file declared the null-returning functions with a native Fix:
Verification:
|
| || $node instanceof Node\Expr\PostInc | ||
| || $node instanceof Node\Expr\PreInc | ||
| || $node instanceof Node\Expr\PostDec | ||
| || $node instanceof Node\Expr\PreDec) !== null; |
There was a problem hiding this comment.
Addressed and pushed as e56caaa5c.
staabm was right — only the Assign branch of containsAssign() was exercised (by $foo->stringOrNull ?? $x = null on line 94). The other six node kinds had no coverage.
What I added to tests/PHPStan/Rules/Variables/data/unnecessary-null-coalesce-side-effects.php — one right side per remaining node kind, each expecting no error:
| Node kind | Right side | Line |
|---|---|---|
AssignRef |
$ref = &$null |
147 |
AssignOp |
$nulls[$i += 1] |
153 |
PostInc |
$nulls[$i++] |
159 |
PreInc |
$nulls[++$i] |
165 |
PostDec |
$nulls[$i--] |
171 |
PreDec |
$nulls[--$i] |
177 |
The four inc/dec cases put the operator inside an array offset on an array<int, null> rather than writing $null++ directly: ++/-- applied to a null variable is deprecated since PHP 8.3 and would be testing a construct nobody writes, while the offset form keeps the right side's type null (verified with \PHPStan\dumpType() — all six infer as null, so each actually reaches the containsAssign() check rather than bailing earlier on the type test). AssignOp needed the same treatment because the only AssignOp that evaluates to null on its own is ??=, which would have nested a second coalesce into the case.
Verification that each branch is load-bearing, not just that the tests pass:
- With
|| $this->containsAssign($right)removed entirely, all 7 assignment cases report the false positive (94, 147, 153, 159, 165, 171, 177); the 6 legitimate reports are unaffected. - Removing each
instanceofclause one at a time makes exactly one case regress —Assign→94,AssignRef→147,AssignOp→153,PostInc→159,PreInc→165,PostDec→171,PreDec→177. No clause is redundant, and no case covers two clauses.
NullCoalesceRuleTest 44/44, make tests 21353 OK (97 skipped, unchanged), make phpstan no errors, make lint clean. The rule source is untouched by this commit; the expected-error list in the test is unchanged since every new case expects no error.
…he right side has side effects * `CoalesceExpressionNode` now also carries the `ExpressionResult` of the right operand, passed in by `CoalesceHandler` (`??`) and `AssignOpHandler` (`??=`). * `NullCoalesceRule::checkUnnecessaryNullCoalesce()` only reports the coalesce as unnecessary when evaluating the right side cannot do anything besides producing its null value: no impure points, no explicit throw points, no yield and no assignment/increment inside it. This mirrors the criterion `ExpressionHandler` already uses to decide whether an expression statement is a noop. * Both operators on the axis are covered: a call returning `null` on the right of `??` and on the right of `??=` are no longer reported. Probed and fixed for function calls, method calls, static calls, `__invoke()` and closure calls, plus plain assignments on the right side (`$a ?? $x = null`), which are not impure points but still change behaviour when the coalesce is deleted. * Side-effect-free right sides (`null`, a `null` constant, a class constant, a `null` variable, a `null`-typed property read, a `@phpstan-pure` call) keep being reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The data file declared `null` return types natively, which is only a type on PHP 8.2+. With a lower `phpVersion` (the tests run on PHP 7.4 too) the same declaration resolves to an object type named `null`, so the right side isn't null and the pure-function case wasn't reported. Declare the null return types in PHPDoc instead so every case exercises what it claims on all analysed PHP versions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the plain `Assign` branch was exercised so far. Add one right side per remaining node kind, each of them inferred as `null` so it reaches the check: `=&`, a nested `+=`, and `++`/`--` in an array offset of an `array<int, null>`. Removing any single `instanceof` clause makes exactly one of these cases report the false positive again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e56caaa to
440f81a
Compare
|
the 7.4 error is pre-existent |
Summary
Coalesce operator ?? is unnecessary because the left side is always set and the right side is null.was reported for$this->parseNumber($value) ?? $this->parseSpace($value)whereparseSpace()is declared to returnnull. Deleting the coalesce there is not equivalent: the right side is still evaluated whenever the left side isnull, and that call can do work and throw.The rule now only reports the coalesce as unnecessary when evaluating the right side cannot do anything besides producing its
nullvalue.Changes
src/Node/CoalesceExpressionNode.php— the node now also carries theExpressionResultof the right operand (getRightResult()).src/Analyser/ExprHandler/CoalesceHandler.php— passes the already-computed$rightResultinto the node.src/Analyser/ExprHandler/AssignOpHandler.php— passes the already-computed$valueResultinto the node for??=.src/Rules/Variables/NullCoalesceRule.php— bails out when the right side has impure points, explicit throw points, a yield, or contains an assignment/increment/decrement.Analogous cases probed and fixed with the same change (all in
tests/PHPStan/Rules/Variables/data/unnecessary-null-coalesce-side-effects.php):??and??=— both operators handled by this rule.__invoke()call and a closure call returningnull.$a ?? $x = null). Assignments to local variables are not impure points, so this was a second, independent instance of the same false positive; it is covered by thecontainsAssign()check, which also covers??=,=&,++and--on the right side.Probed and deliberately left reported, because deleting them really is a no-op: literal
null, a global constant and a class constant whose value isnull, anullvariable, a read of anull-typed property, and a call to a@phpstan-purefunction returningnull.Root cause
checkUnnecessaryNullCoalesce()reasoned purely about values: if the left side is always set and the right side's type isnull, the result of the expression is the same with or without the coalesce. That ignores that??is short-circuiting — the right side is evaluated exactly when the left side isnull, so deleting the operator also deletes that evaluation. Any right side that is not side-effect free therefore made the report a false positive.The fix reuses the criterion the codebase already applies when deciding whether an expression statement does nothing (
src/Analyser/StmtHandler/ExpressionHandler.php: no impure points, no explicit throw points, and no assignment). To get at it the right side'sExpressionResult, which the two handlers already compute, is now handed to the rule throughCoalesceExpressionNodeinstead of being thrown away.Test
tests/PHPStan/Rules/Variables/data/bug-15134.php+NullCoalesceRuleTest::testBug15134()— the reproducer from the issue's playground link, expecting no errors. It fails before the fix with the reported false positive on the??line.tests/PHPStan/Rules/Variables/data/unnecessary-null-coalesce-side-effects.php+NullCoalesceRuleTest::testUnnecessaryNullCoalesceSideEffects()— covers the analogous right-side constructs listed above. Before the fix it produces 7 additional false positives (function/method/static/__invoke/closure calls, the assignment right side, and the??=call right side) on top of the 6 reports that are correct and are kept.NullCoalesceRuleTest(bug-4337,bug-12179,bug-9966,bug-14213) are unchanged — those all use side-effect-free right sides.Fixes phpstan/phpstan#15134