Skip to content

[DeadCode] Use PHPStan flow analysis for property initialization in RemoveDefaultValueFromAssignedPropertyRector#8210

Open
TomasVotruba wants to merge 3 commits into
mainfrom
phpstan-flow-analysis-default-value
Open

[DeadCode] Use PHPStan flow analysis for property initialization in RemoveDefaultValueFromAssignedPropertyRector#8210
TomasVotruba wants to merge 3 commits into
mainfrom
phpstan-flow-analysis-default-value

Conversation

@TomasVotruba

@TomasVotruba TomasVotruba commented Jul 27, 2026

Copy link
Copy Markdown
Member

Follow-up to #8209 (comment)@staabm suggested basing the rule on PHPStan's own uninitialized-property mechanics instead of hand-rolled AST scanning.

New service

Rector\NodeAnalyzer\AlwaysInitializedPropertyAnalyzer answers one question, for any class method:

$this->alwaysInitializedPropertyAnalyzer->isAlwaysInitialized($classMethod, $propertyName);

Under the hood it merges the scope of every exit point of that method and asks PHPStan whether the property is initialized there — the same query PHPStan's own ClassPropertiesNode::collectUninitializedProperties() uses:

$executionEndScope->hasExpressionType(new PropertyInitializationExpr($propertyName))->yes()

It is not tied to constructors, so any rule that today guesses at "is this property assigned" can use it. ConstructorAssignDetector has 8 other consumers that inherit the same blind spots this replaces — migrating them is a separate change.

Wiring: PHPStanNodeScopeResolver already receives every virtual node PHPStan emits, it just dropped them. It now picks up MethodReturnStatementsNode and stores it on the matching ClassMethod under AttributeKey::METHOD_RETURN_STATEMENTS_NODE. PHPStan does not expose the ClassMethod on that virtual node, but it copies the method's attributes onto it, so the start file position pairs them back up.

Rule change

The Return_-scanning heuristic added in #8209 is gone — PHPStan handles it structurally, plus everything the heuristic could not.

Early return no longer blocks the whole constructor, only paths that actually skip the assign:

 final class ReturnAfterAssign
 {
-    private ?string $name = null;
+    private ?string $name;

     public function __construct(string $name, bool $shouldReturn = false)
     {
         $this->name = $name;

         if ($shouldReturn) {
             return;
         }

         $this->name = strtoupper($name);
     }
 }

if/elseif/else, switch with default, and a throwing branch are all understood:

 final class AssignInIfElseIfElse
 {
-    private ?string $name = null;
+    private ?string $name;

     public function __construct(int $type)
     {
         if ($type === 1) {
             $this->name = 'one';
         } elseif ($type === 2) {
             $this->name = 'two';
         } else {
             $this->name = 'many';
         }
     }
 }

Still skipped: assign inside foreach (loop may not run), inside try only, inside a closure that is never invoked, and an assign that reads the default first ($this->count = $this->count + $extra).

Bug fixed on the way

Offset writes were treated as full assigns, so this used to produce a fatal must not be accessed before initialization:

final class SkipAssignViaOffsetAccess
{
    private array $cachedByName = [];

    public function __construct(string $name)
    {
        $this->cachedByName[$name] = true;
    }
}

It was hit by Rector\Privatization\Guard\ParentClassMagicCallGuard when running Rector on this repo itself. Now skipped.

Performance

Kept for the record. All runs: bin/rector process --dry-run --no-progress-bar --no-diffs --clear-cache, withoutParallel(), only this rule registered, PHP 8.4.23. Branches alternated every repetition so machine drift hits both sides equally; 6 repetitions per side, median reported.

Corpora:

  • scale1000 — 1000 files, one class each, one property with a default plus a constructor that assigns it (the case asked about: this rule firing 1000 times)
  • methods — same 1000 classes, each with 10 extra methods that never touch a property
  • real code — this repo's own src/ + rules/, 1317 files
corpus time before time after delta peak mem before peak mem after delta
scale1000 1.65 s 1.66 s +0.6% 146.8 MB 160.7 MB +9.4%
methods 8.88 s 9.02 s +1.6% 607.8 MB 620.4 MB +2.1%
real code 27.94 s 27.56 s −1.4% 889.3 MB 936.3 MB +5.3%

Time is a wash. Memory grows because the exit point scopes stay reachable after analysis.

Two earlier variants were measured and rejected:

variant scale1000 time scale1000 mem methods time methods mem
merge scopes eagerly in the resolver −1.8% +7.3% +6.7% +6.7%
store the node, merge lazily, no filter +1.2% +9.5% −0.2% +32.7%
shipped: lazy + only decorate methods that assign a property +0.6% +9.4% +1.6% +2.1%

Eager merging costs real time on method-heavy code. Storing the raw node for every method costs a lot of memory, because it pins every exit point scope. Doing both — decorate only methods that contain a $this->... assign, and merge on demand — keeps each within noise on real code.

Tests

  • tests/NodeAnalyzer/AlwaysInitializedPropertyAnalyzerTest.php — 6 cases against the service directly, via TestingParser so the nodes carry real PHPStan scopes
  • 9 new rule fixtures (17 total). Sanity check: stubbing out the PHPStan query fails 5 of them, so they exercise the new path rather than the old one.

…emoveDefaultValueFromAssignedPropertyRector

Decorate every ClassMethod with the scope merged from all its execution
ends and return statements, so rules can ask PHPStan whether a property
is initialized on every path out of the constructor.

This replaces the hand-rolled "any return bails out" heuristic and covers
if/elseif/else, switch, loops, try/catch and closures for free.
…scope lazily

The scope resolver now only stores the raw MethodReturnStatementsNode on
the ClassMethod. Merging the exit point scopes happens in the new
analyzer, on demand, so methods no rule asks about cost nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant