Report values assigned to variables that are never read - #6330
Merged
Conversation
ondrejmirtes
force-pushed
the
resolve-type-rewrite-2
branch
from
September 2, 2026 13:15
2aca59b to
00f2b74
Compare
ondrejmirtes
force-pushed
the
unused-variables
branch
4 times, most recently
from
September 8, 2026 09:30
2152a04 to
88d0188
Compare
staabm
reviewed
Sep 8, 2026
Comment on lines
+16
to
+22
| public function publicMethod(int $unused): void | ||
| { | ||
| } | ||
|
|
||
| protected function protectedMethod(int $unused): void | ||
| { | ||
| } |
Contributor
There was a problem hiding this comment.
I think we could report it for these method too, when the class (or method) is final and does not override a method from a base-class
Member
Author
There was a problem hiding this comment.
I know but I'd leave it for later, I have an entire idea about this. Right now options such as checkTooWideReturnTypesInProtectedAndPublicMethods and others are very clunky. I'd like to replace it one setting: "is the project open or closed"? Or in other words "are we seeing all the usages of all the classes or are there more?".
staabm
reviewed
Sep 8, 2026
ondrejmirtes
force-pushed
the
unused-variables
branch
6 times, most recently
from
September 10, 2026 08:36
5de5f2f to
d21d791
Compare
ondrejmirtes
force-pushed
the
unused-variables
branch
from
September 10, 2026 12:00
bd5161e to
4f5a86e
Compare
ondrejmirtes
force-pushed
the
unused-variables
branch
from
September 10, 2026 12:03
4f5a86e to
5195842
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reports local variables whose assigned value is never read afterwards on any path (
variable.unused, level 4, behind the newunusedVariablebleeding-edge toggle).Temporarily enable unusedVariable for everyonebefore merging.How it works
A write is dead iff no path from it reaches a read of the written value — so
$a = 1; $a = 2; echo $a;reports line 1 andif (c) { $a = 1; } return;reports the write, whilefor ($i = 0; $i < 3; $i++), back-edge reads in loops,$x[] = ...; return $x;etc. do not.list()item,foreachvalue/key, catch variable) becomes an immutableVariableWritewith aVariableWrittenExprmarker in the scope's expression types (__phpstanVariableWritten($x, <id>), listed as a compositional virtual key, PHP + turbo mirror). A new write of the same variable kills the earlier markers explicitly (assignVariable(..., write:, supersededMarkerExprs:)— the scope is told what to kill, it never consults engine state); merges keep markers as Maybe, so at any point they say which writes still reach it.VariableHandler) records the reaching writes as read in an immutable, persistentVariableWritesFrame(one per function-like body;NodeScopeResolverholds the stack and swaps the top after eachwith*()transition; arrow functions share the enclosing frame;processNodesisolates the stack).compact(),get_defined_vars(),extract(),eval,includeread everything;gotomakes the frame opaque; by-ref parameters/uses,global,staticand reference aliases are untracked.VariableWritesNodeafter each*ReturnStatementsNode;UnusedVariableRulereports the unread, tracked writes (catch variables only where non-capturing catches exist;$_-prefixed names are exempt).Engine fixes needed on the way:
generalizeWith()now carries markers planted only by a later loop pass (a branch dead while the variable was stillnull),createConditionalExpressions()no longer records certainty-No conditionals for virtual nodes (a later narrowing could have erased a marker),ClosureTypeResolver's body walk gets its own throwaway frame, and reads walked in consume-stored mode (arguments of a nullsafe call's plain twin) count as reads.The first commit removes the 30 genuine dead stores the rule found in
src/andtests/.Verification
make testsgreen turbo-off and turbo-on,make phpstanclean in both modes, cs/lint clean.list()target and an offset write on an array the closure had already captured by value) + 7 writes in branches PHPStan itself proves unreachable (always-falseis_int()guard, impossibleisset(), dead catch) — left as is, consistent with the existing*.alwaysFalse/catch.neverThrowndiagnostics.src/Typealone the tracking is ≈ 3.7 % (reads ≈ 2.5 %, scope markers ≈ 1–2.7 %).Follow-ups (not in this PR)
$b = $b + 1chains that never reach a sink): reads in pure RHS positions become dependency edges between writes, the rule computes a fixpoint.$a = ['a' => 1, 'b' => 2]with only$a['b']read) — bounded per-offset sub-writes.$x = 1; unset($x);(unset()walks the variable as a read).f($raw = true)(value consumed, variable never read) and writes in PHPStan-proven-dead branches should be reported is a policy call.Closes phpstan/phpstan#12789
Closes phpstan/phpstan#12012
Closes phpstan/phpstan#11483
Closes phpstan/phpstan#10202
🤖 Generated with Claude Code
https://claude.ai/code/session_01CbZPnVnRJDLbnqmtD3sYSy