Skip to content

Report values assigned to variables that are never read - #6330

Merged
ondrejmirtes merged 33 commits into
2.3.xfrom
unused-variables
Sep 10, 2026
Merged

Report values assigned to variables that are never read#6330
ondrejmirtes merged 33 commits into
2.3.xfrom
unused-variables

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 1, 2026

Copy link
Copy Markdown
Member

Reports local variables whose assigned value is never read afterwards on any path (variable.unused, level 4, behind the new unusedVariable bleeding-edge toggle).

⚠️ The last commit enables the toggle for everyone on purpose, so that CI and the downstream dogfooding projects surface false positives — revert Temporarily enable unusedVariable for everyone before 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 and if (c) { $a = 1; } return; reports the write, while for ($i = 0; $i < 3; $i++), back-edge reads in loops, $x[] = ...; return $x; etc. do not.

  • Every source-level write site of a local variable (assignment, compound assignment, inc/dec, offset write on an array/string, list() item, foreach value/key, catch variable) becomes an immutable VariableWrite with a VariableWrittenExpr marker 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.
  • The one place a source-level read is priced (VariableHandler) records the reaching writes as read in an immutable, persistent VariableWritesFrame (one per function-like body; NodeScopeResolver holds the stack and swaps the top after each with*() transition; arrow functions share the enclosing frame; processNodes isolates the stack). compact(), get_defined_vars(), extract(), eval, include read everything; goto makes the frame opaque; by-ref parameters/uses, global, static and reference aliases are untracked.
  • The frame is emitted as a VariableWritesNode after each *ReturnStatementsNode; UnusedVariableRule reports 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 still null), 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/ and tests/.

Verification

  • Rule test with 31 cases (Psalm's valid/invalid catalogue as the trap list; fail-first verified), make tests green turbo-off and turbo-on, make phpstan clean in both modes, cs/lint clean.
  • Dogfood on slevomat (bleeding edge, final build): 48 reports = 41 true positives (two real bugs among them: a duplicate 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-false is_int() guard, impossible isset(), dead catch) — left as is, consistent with the existing *.alwaysFalse / catch.neverThrown diagnostics.
  • Performance (locally built phars, fork + turbo, cold cache, ABBA pairs): slevomat +0.7 % user CPU (5 pairs, t = 0.8, n.s.); self-analysis +1.55 % (8 pairs, sd 0.82, t = 6.4); on src/Type alone the tracking is ≈ 3.7 % (reads ≈ 2.5 %, scope markers ≈ 1–2.7 %).

Follow-ups (not in this PR)

  • Psalm's value-flow refinement ($b = $b + 1 chains that never reach a sink): reads in pure RHS positions become dependency edges between writes, the rule computes a fixpoint.
  • Unused array-literal offsets ($a = ['a' => 1, 'b' => 2] with only $a['b'] read) — bounded per-offset sub-writes.
  • Known false negative: $x = 1; unset($x); (unset() walks the variable as a read).
  • Whether 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

Comment on lines +16 to +22
public function publicMethod(int $unused): void
{
}

protected function protectedMethod(int $unused): void
{
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?".

Comment thread tests/PHPStan/Rules/Functions/data/unused-function-parameters.php
@ondrejmirtes
ondrejmirtes force-pushed the unused-variables branch 6 times, most recently from 5de5f2f to d21d791 Compare September 10, 2026 08:36
@ondrejmirtes
ondrejmirtes merged commit 8e2e9f6 into 2.3.x Sep 10, 2026
201 of 203 checks passed
@ondrejmirtes
ondrejmirtes deleted the unused-variables branch September 10, 2026 12:04
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.

Detect Assignment to Unused Variable detect local dead code and assigments Detect useless assignment report redundant assignment as dead code

2 participants