Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Do not report values flowing into a write that is reported as never read
A write whose value only flows into unused values was reported even when
one of those values is itself never read - `$copy = $e` already reports
$copy, reporting $e as well is noise. The liveness resolver now walks the
dependency edges backwards from every never-read write and marks the
writes feeding it as covered; the rules skip covered flow reports. A
chain that only feeds itself, like Psalm's `$b = $b + 1` loop, has no
never-read write to point at and stays reported at every write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CZ79ZBiMt4KWgU3uy4Y4j
  • Loading branch information
ondrejmirtes and claude committed Sep 11, 2026
commit 59b243fab985f1a0cf850c1f458b6ae165ea664b
42 changes: 41 additions & 1 deletion src/Analyser/VariableLivenessResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ final class VariableLivenessResolver
/** @var array<int, array<int, VariableWrite>> */
private array $literalItems = [];

/** @var array<int, true> */
private array $coveredIds = [];

/** @var array<string, true> */
private array $allReadKeys = [];

Expand Down Expand Up @@ -119,9 +122,10 @@ public static function resolve(Node\FunctionLike $function, ?VariableFlow $flow)
$self->compileAccesses();
$self->liveBefore($body, [], new VariableFlowContext([]));
$self->resolveDependencies();
$self->resolveCoverage();
}

return new VariableWritesNode($function, array_values($self->writes), $self->observedIds + $self->readIds, $self->readIds, $self->readNames, $self->redundantTypes, $self->mentionedNames, $self->escapedNames, $self->opaque, $self->allNamesMentioned);
return new VariableWritesNode($function, array_values($self->writes), $self->observedIds + $self->readIds, $self->readIds, $self->coveredIds, $self->readNames, $self->redundantTypes, $self->mentionedNames, $self->escapedNames, $self->opaque, $self->allNamesMentioned);
}

private function collect(?VariableFlow $flow, bool $dead = false): void
Expand Down Expand Up @@ -495,4 +499,40 @@ private function resolveDependencies(): void
}
}

/**
* A write whose value flows only into unused writes is covered when one of
* them is never read at all: reporting that write already points at the
* dead chain. Walks the dependency edges backwards from every never-read
* write; a chain that only feeds itself (Psalm's `$b = $b + 1` loop) has
* no such write and stays uncovered.
*/
private function resolveCoverage(): void
{
$stack = [];
foreach (array_keys($this->writes) as $id) {
if (isset($this->observedIds[$id]) || isset($this->readIds[$id])) {
continue;
}

$stack[] = $id;
}
$visited = [];
while ($stack !== []) {
$id = array_pop($stack);
if (isset($visited[$id])) {
continue;
}
$visited[$id] = true;
$sources = array_keys($this->dependencies[$id] ?? []);
foreach (array_keys($this->inputCopies[$id] ?? []) as $copied) {
// the inputs of $copied flow into $id as well
$sources = [...$sources, ...array_keys($this->dependencies[$copied] ?? [])];
}
foreach ($sources as $source) {
$this->coveredIds[$source] = true;
$stack[] = $source;
}
}
}

}
11 changes: 11 additions & 0 deletions src/Node/VariableWritesNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class VariableWritesNode extends NodeAbstract implements VirtualNode
* @param list<VariableWrite> $writes
* @param array<int, true> $readWriteIds
* @param array<int, true> $usedWriteIds
* @param array<int, true> $coveredWriteIds
* @param array<string, true> $readVariableNames
* @param array<int, Type> $redundantWriteTypes
* @param array<string, true> $referencedVariableNames
Expand All @@ -34,6 +35,7 @@ public function __construct(
private array $writes,
private array $readWriteIds,
private array $usedWriteIds,
private array $coveredWriteIds,
private array $readVariableNames,
private array $redundantWriteTypes,
private array $referencedVariableNames,
Expand Down Expand Up @@ -88,6 +90,15 @@ public function isUsed(VariableWrite $write): bool
return isset($this->usedWriteIds[$write->getId()]);
}

/**
* Whether the value flows into a write that is never read at all - that
* write is the one to report, this one only feeds it.
*/
public function flowsIntoNeverReadWrite(VariableWrite $write): bool
{
return isset($this->coveredWriteIds[$write->getId()]);
}

/** Whether some path from the write reaches a read of the written value. */
public function isRead(VariableWrite $write): bool
{
Expand Down
2 changes: 1 addition & 1 deletion src/Rules/DeadCode/UnusedVariableRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public function processNode(Node $node, Scope $scope): array
continue;
}

if ($node->isUsed($write)) {
if ($node->isUsed($write) || $node->flowsIntoNeverReadWrite($write)) {
continue;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Rules/Functions/UnusedClosureUsesRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function processNode(Node $node, Scope $scope): array
if ($node->isRead($write)) {
// read, but only into values that never reach a sink - a
// newer finding than the rule, so bleeding edge only
if (!$this->reportUnusedFlow) {
if (!$this->reportUnusedFlow || $node->flowsIntoNeverReadWrite($write)) {
continue;
}
$message = 'Anonymous function has a use $%s that only flows into values that are never used.';
Expand Down
2 changes: 1 addition & 1 deletion src/Rules/UnusedParametersCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function getUnusedParameterErrors(
continue;
}
if ($node->isRead($write)) {
if ($unusedFlowMessage === null || $unusedFlowIdentifier === null) {
if ($unusedFlowMessage === null || $unusedFlowIdentifier === null || $node->flowsIntoNeverReadWrite($write)) {
continue;
}
$message = $unusedFlowMessage;
Expand Down
14 changes: 4 additions & 10 deletions tests/PHPStan/Analyser/AnalyserIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1407,16 +1407,10 @@ public function testBug10847(): void
{
$errors = $this->runAnalyse(__DIR__ . '/data/bug-10847.php');
// The loop appends to $overloads instead of $processedOverloads.
$this->assertCount(3, $errors);
$this->assertSame('Foreach key variable $args only flows into values that are never used.', $errors[0]->getMessage());
$this->assertSame('foreach.unusedKeyFlow', $errors[0]->getIdentifier());
$this->assertSame(158, $errors[0]->getLine());
$this->assertSame('Foreach value variable $callback only flows into values that are never used.', $errors[1]->getMessage());
$this->assertSame('foreach.unusedValueFlow', $errors[1]->getIdentifier());
$this->assertSame(158, $errors[1]->getLine());
$this->assertSame('Value assigned to $overloads[] is never read.', $errors[2]->getMessage());
$this->assertSame('assign.unused', $errors[2]->getIdentifier());
$this->assertSame(159, $errors[2]->getLine());
$this->assertCount(1, $errors);
$this->assertSame('Value assigned to $overloads[] is never read.', $errors[0]->getMessage());
$this->assertSame('assign.unused', $errors[0]->getIdentifier());
$this->assertSame(159, $errors[0]->getLine());
}

#[RequiresPhp('>= 8.1.0')]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ public function testResolvedDynamicUsages(): void
public function testValueFlow(): void
{
$this->analyse([__DIR__ . '/data/unused-constructor-parameters-value-flow.php'], [
['Constructor of class UnusedConstructorParametersValueFlow\Foo has a parameter $covered that only flows into values that are never used.', 10],
['Constructor of class UnusedConstructorParametersValueFlow\Foo has a parameter $input that only flows into values that are never used.', 10],
['Constructor of class UnusedConstructorParametersValueFlow\Foo has an unused parameter $overwritten.', 10],
]);
Expand Down
41 changes: 0 additions & 41 deletions tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ public function testRule(): void
['Variable $x is never read.', 223],
['Value assigned to $x[] is never read.', 250],
['Value assigned to $x[\'k\'] is never read.', 251],
['Value assigned to variable $s only flows into values that are never used.', 263],
['Value assigned to variable $s is never read.', 264],
['Variable $a is never read.', 269],
['Variable $a is never read.', 276],
Expand All @@ -96,7 +95,6 @@ public function testRule(): void
['Variable $b is never read.', 614],
['Variable $a is never read.', 632],
['Variable $a is never read.', 637],
['Value assigned to variable $a only flows into values that are never used.', 702],
['Value assigned to variable $a is never read.', 703],
['Value assigned to variable $b only flows into values that are never used.', 709],
['Value assigned to variable $b only flows into values that are never used.', 719],
Expand All @@ -117,7 +115,6 @@ public function testPhp8(): void
$this->analyse([__DIR__ . '/data/unused-variable-php8.php'], [
['Catch variable $e is never read.', 23],
['Value assigned to variable $nightsFrom is never read.', 98],
['Value assigned to variable $a only flows into values that are never used.', 107],
['Variable $b is never read.', 108],
['Variable $b is never read.', 116],
]);
Expand Down Expand Up @@ -160,7 +157,6 @@ public function testBug14258(): void
public function testBug12012(): void
{
$this->analyse([__DIR__ . '/data/bug-12012.php'], [
['Value assigned to variable $s1 only flows into values that are never used.', 9],
['Value assigned to variable $s1 is never read.', 10],
['Value assigned to variable $s1 is never read.', 12],
]);
Expand Down Expand Up @@ -301,42 +297,26 @@ public function testBroadCatchesIncludeImplicitThrows(): void
public function testValueFlow(): void
{
$this->analyse([__DIR__ . '/data/unused-variable-value-flow.php'], [
['Value assigned to variable $a only flows into values that are never used.', 27],
['Value assigned to variable $a is never read.', 28],
['Value assigned to variable $s only flows into values that are never used.', 40],
['Value assigned to variable $s only flows into values that are never used.', 41],
['Value assigned to variable $s is never read.', 42],
['Value assigned to variable $i only flows into values that are never used.', 54],
['Value of variable $i after ++ only flows into values that are never used.', 55],
['Value of variable $i after ++ only flows into values that are never used.', 56],
['Value of variable $i after -- only flows into values that are never used.', 57],
['Value of variable $i after -- is never read.', 58],
['Value of variable $i after ++ is never read.', 71],
['Value assigned to variable $i only flows into values that are never used.', 76],
['Value of variable $i after ++ is never read.', 77],
['Variable $j is never read.', 77],
['Value assigned to variable $n only flows into values that are never used.', 98],
['Value assigned to variable $n only flows into values that are never used.', 100],
['Value assigned to variable $a only flows into values that are never used.', 117],
['Variable $ok is never read.', 118],
['Value assigned to variable $a only flows into values that are never used.', 130],
['Variable $b is never read.', 131],
['Variable $b is never read.', 144],
['Variable $b is never read.', 150],
['Value assigned to variable $a only flows into values that are never used.', 155],
['Variable $arr is never read.', 156],
['Value assigned to variable $a only flows into values that are never used.', 168],
['Variable $b is never read.', 169],
['Variable $c is never read.', 170],
['Value assigned to variable $a only flows into values that are never used.', 175],
['Variable $b is never read.', 176],
['Variable $c is never read.', 177],
['Variable $d is never read.', 178],
['Value assigned to variable $a only flows into values that are never used.', 183],
['Variable $b is never read.', 184],
['Value assigned to variable $a only flows into values that are never used.', 189],
['Variable $b is never read.', 190],
['Value assigned to variable $d only flows into values that are never used.', 195],
['Variable $b is never read.', 196],
['Variable $b is never read.', 202],
['Variable $b is never read.', 209],
Expand All @@ -356,31 +336,20 @@ public function testValueFlow(): void
['Variable $b is never read.', 289],
['Variable $a is never read.', 295],
['Variable $b is never read.', 302],
['Value assigned to variable $c only flows into values that are never used.', 308],
['Variable $b is never read.', 309],
['Variable $a is never read.', 309],
['Variable $b is never read.', 315],
['Value assigned to variable $a only flows into values that are never used.', 321],
['Variable $b is never read.', 322],
['Value assigned to variable $v only flows into values that are never used.', 329],
['Value assigned to $a[\'x\'] is never read.', 331],
['Value assigned to variable $a only flows into values that are never used.', 344],
['Variable $b is never read.', 345],
['Value assigned to variable $i only flows into values that are never used.', 357],
['Value assigned to variable $a only flows into values that are never used.', 358],
['Variable $b is never read.', 359],
['Variable $a is never read.', 364],
['Value assigned to variable $a only flows into values that are never used.', 369],
['Value assigned to variable $b only flows into values that are never used.', 370],
['Value assigned to variable $c only flows into values that are never used.', 371],
['Variable $d is never read.', 372],
['Value assigned to variable $s only flows into values that are never used.', 395],
['Foreach value variable $v only flows into values that are never used.', 396],
['Value assigned to variable $s only flows into values that are never used.', 397],
['Value assigned to variable $a only flows into values that are never used.', 403],
['Value assigned to variable $a is never read.', 404],
['Value assigned to variable $s is never read.', 477],
['Value assigned to variable $s only flows into values that are never used.', 483],
['Value assigned to variable $s is never read.', 484],
['Variable $x is never read.', 484],
]);
Expand All @@ -403,7 +372,6 @@ public function testOffsets(): void
['Offset 2 of array assigned to variable $a is never read.', 136],
['Offset 6 of array assigned to variable $a is never read.', 142],
['Offset \'x\' of array assigned to variable $a is never read.', 149],
['Value assigned to variable $v only flows into values that are never used.', 156],
['Offset \'x\' of array assigned to variable $a is never read.', 157],
['Offset \'y\' of array assigned to variable $a is never read.', 163],
['Offset \'y\' of array assigned to variable $a is never read.', 169],
Expand All @@ -415,9 +383,7 @@ public function testOffsets(): void
['Value assigned to $a[] is never read.', 247],
['Value assigned to $a[\'x\'][\'y\'] is never read.', 260],
['Offset \'x\' of array assigned to variable $a is never read.', 280],
['Value assigned to variable $a only flows into values that are never used.', 294],
['Value assigned to $a[\'x\'] is never read.', 295],
['Value assigned to variable $a only flows into values that are never used.', 314],
['Value of $a[\'n\'] after ++ is never read.', 315],
['Value assigned to $s[0] is never read.', 328],
['Value assigned to $a[\'x\'] is never read.', 356],
Expand All @@ -436,7 +402,6 @@ public function testOffsets(): void
['Value assigned to $cache[\'x\'] is never read.', 565],
['Variable $v is never read.', 565],

['Offset \'x\' of array assigned to variable $a only flows into values that are never used.', 570],
['Variable $b is never read.', 571],
]);
}
Expand Down Expand Up @@ -499,15 +464,10 @@ public function testFlowMessages(): void
['Value assigned to variable $k only flows into values that are never used.', 86],
['Offset \'x\' of array assigned to variable $a only flows into values that are never used.', 93],
['Value assigned to $a[\'x\'] only flows into values that are never used.', 95],
['Value assigned to variable $a only flows into values that are never used.', 102],
['Variable $b is never read.', 103],
['Value assigned to variable $a only flows into values that are never used.', 108],
['Value assigned to variable $a only flows into values that are never used.', 109],
['Value assigned to variable $a is never read.', 110],
['Value assigned to variable $c only flows into values that are never used.', 115],
['Variable $a is never read.', 116],
['Variable $b is never read.', 116],
['Value assigned to variable $v only flows into values that are never used.', 121],
['Offset \'x\' of array assigned to variable $a is never read.', 122],
]);
}
Expand All @@ -518,7 +478,6 @@ public function testFlowMessagesCatch(): void
$this->analyse([__DIR__ . '/data/unused-variable-flow-messages-catch.php'], [
['Catch variable $e only flows into values that are never used.', 15],
['Value assigned to variable $e only flows into values that are never used.', 17],
['Catch variable $e only flows into values that are never used.', 26],
['Variable $copy is never read.', 27],
]);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/PHPStan/Rules/DeadCode/data/unused-variable-offsets.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function literalOffsetUnset(): void

function literalItemValueFlow(): void
{
$v = source(); // unused $v
$v = source();
$a = ['x' => $v, 'y' => 2]; // unused offset 'x' of $a
sink($a['y']);
}
Expand Down Expand Up @@ -567,7 +567,7 @@ function dimWriteCoalesceAssignValueFlow(): void

function literalItemReadIntoUnused(): void
{
$a = ['x' => 1, 'y' => 2]; // offset 'x' of $a only flows into values that are never used
$a = ['x' => 1, 'y' => 2];
$b = $a['x']; // unused $b
sink($a['y']);
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Rules/DeadCode/data/unused-variable-php8.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public function unreadArgumentVariable(): void

function matchArmFlow(): void
{
$a = source(); // unused $a
$a = source();
$b = match (true) { // unused $b
default => $a,
};
Expand Down
Loading
Loading