Keep subtype-absorbed variables as conditional-expression targets when merging branches#5876
Keep subtype-absorbed variables as conditional-expression targets when merging branches#5876phpstan-bot wants to merge 3 commits into
Conversation
…n merging branches
- In MutatingScope::createConditionalExpressions, a variable whose our-branch
type is a subtype of the other branch's type makes the union absorb it
(merged === their). Such a variable was dropped from $newVariableTypes
entirely, so no conditional expression was recorded for it. With `mixed`
(which absorbs `null`) this meant `if (cond) { $x = null; }` produced no
"cond => $x is null" relationship, while `int` (where `int|null` does not
collapse) did.
- Instead of unsetting such variables, collect them in $guardsToExclude and
skip them only during type-guard selection. They are poor guards (asserting
their narrowed type wouldn't reliably select the branch) but remain valid
conditional *targets*, so the relationship is now preserved.
- Updated nsrt tests that become more precise as a result (bug-5051, bug-8467b,
non-empty-string-str-containing-fns) and TypesAssignedToPropertiesRuleTest
(a dynamic property fetch that was previously *ERROR* now resolves to its
real type).
- Removed a now-redundant `$parametersAcceptor !== null` check in FuncCallHandler
(always true given `$functionReflection !== null`) and added @PHPStan-Ignore
comments in OptimizedDirectorySourceLocator where the sharper narrowing of a
single-element `$files` array surfaces a provably-false null check and a
foreach value variable that is in fact always defined (matching the existing
ignore in the sibling function branch).
| } | ||
|
|
||
| if ($fetchedClassNode === null) { | ||
| if ($fetchedClassNode === null) { // @phpstan-ignore identical.alwaysFalse |
There was a problem hiding this comment.
We shouldn't have to ignore error, it implies something is wrong.
If it's really always false it can be removed (other way there is a bug).
If a variable is reported as undefined, it means we have a bad inference in types or that we're introducing a bug (since nothing was reported prior to this pr)
311ac20 to
80280f6
Compare
| } | ||
|
|
||
| [$reflectionCacheKey, $variableCacheKey] = $this->getCacheKeys($file, $identifier); | ||
| [$reflectionCacheKey, $variableCacheKey] = $this->getCacheKeys($file, $identifier); // @phpstan-ignore variable.undefined |
There was a problem hiding this comment.
Why $file is considered as maybe undefined when
- it's defined by the foreach
foreach ($files as $file)(on a non empty array) - it's even defined on the previous if
isClasswith the line
$file = $this->findFileByClass($identifierName);
And why it's only reported since this PR ?
There was a problem hiding this comment.
I've addressed VincentLanglet's review. Here's a summary for the PR reply:
What changed
I removed both @phpstan-ignore comments in the class branch of OptimizedDirectorySourceLocator::locateIdentifier() by restructuring rather than suppressing (commit d19913dba).
Answering the reviewer's question
Why was $file reported as maybe-undefined, and why only since this PR?
The variable surfaced precisely because this PR made inference sharper, not because the PR introduced a bug:
- For a class identifier,
$filesis always the single-element[$file](line 74). This PR's conditional-expression mechanism now resolves it to a non-emptyarray{string}. - That made the post-loop
if ($fetchedClassNode === null)check dead code — the loop always runs and always assigns aFetchedNode, so it's never null (identical.alwaysFalse). - On the parent commit, that null-check was a live branch. Its
!== nullpath implicitly re-established$fileas defined: the only way$fetchedClassNodecould be non-null was if the loop had run, which means$filewas assigned. Once the branch became dead, that accidental re-establishment vanished and the standard "foreach value variable is possibly-undefined after the loop" verdict resurfaced. So the warning was correct — suppressing it was the wrong call.
The fix
Since the class branch's $files is always single-element, I moved the reflection-building inside the loop and return from there. Now $file is the live loop variable (never undefined) and the dead null-check is gone entirely. The trailing return null; after the loop was then unreachable (the loop provably runs and always returns), so it was dropped too. No behavior change.
The pre-existing @phpstan-ignore variable.undefined in the function branch (line ~147) is untouched — there $files comes from findFilesByFunction() and can genuinely be empty/multi-element with continue, so that's a real, long-standing limitation unrelated to this PR.
Verification
make phpstan— green (no ignores, no errors)OptimizedDirectorySourceLocatorTest— 33/33 pass- Full suite — 17387/17387 pass
There was a problem hiding this comment.
Keep the code out of the for loop, just remove the null check.
There was a problem hiding this comment.
Done. Here's a summary for the PR reply:
What changed
I moved the class reflection-building back out of the foreach loop and removed the dead null check, per your request (commit 65136a323). The class branch now reads:
if ($identifier->isClass()) {
$fetchedClassNode = null;
$fetchedFile = null;
foreach ($files as $file) {
$fetchedClassNodes = $this->fileNodesFetcher->fetchNodes($file)->getClassNodes();
if (!array_key_exists($identifierName, $fetchedClassNodes)) {
return null;
}
/** @var FetchedNode<Node\Stmt\ClassLike> $fetchedClassNode */
$fetchedClassNode = current($fetchedClassNodes[$identifierName]);
$fetchedFile = $file;
}
[$reflectionCacheKey, $variableCacheKey] = $this->getCacheKeys($fetchedFile, $identifier);
$classReflection = $this->nodeToReflection($reflector, $fetchedClassNode);
$this->cache->save($reflectionCacheKey, $variableCacheKey, $classReflection->exportToCache());
return $classReflection;
}No @phpstan-ignore anywhere — make phpstan green, locator test 33/33, full suite 17387/17387.
Why "just remove the null check" alone wasn't enough
A bare removal leaves $file reported as variable.undefined at getCacheKeys($file). The reason is subtle and worth spelling out, because it answers your earlier "why only since this PR?":
$fileis the foreach value variable — PHPStan treats it as possibly-undefined after the loop regardless of the array being non-empty (this is broad, long-standing behavior:foreach ([1,2,3] as $x) {} echo $x;reports it onmaintoo).- On
mainthe class branch was accidentally clean:$fileswas the imprecisearray, soif ($fetchedClassNode === null)was a live branch whose!== nullpath implicitly re-established$fileas defined. This PR's sharper inference proves$filesis non-empty, which both makes that check dead (identical.alwaysFalse) and removes the mechanism that was keeping$filedefined. So removing the dead check exposes the value-variable limitation.
The fix is to capture the file into $fetchedFile, initialized before the loop — exactly like the pre-existing $fetchedClassNode = null. Its definedness is then proven the same way the dead === null check is proven dead: by the non-empty-array narrowing (string|null → string after the always-running loop), not by the loop value variable. That's why no ignore is needed here, unlike the function branch below where $files genuinely can be empty.
…ator Removes the two @PHPStan-Ignore comments in the class branch of locateIdentifier() by restructuring instead of suppressing. For a class identifier, $files is always the single-element [$file], which the sharper conditional-expression inference from this PR now resolves to a non-empty array{string}. That made two things visible: - The post-loop `if ($fetchedClassNode === null)` check became dead (identical.alwaysFalse): the loop always runs and always assigns a FetchedNode, so the value is never null. - The post-loop `getCacheKeys($file, ...)` used $file, which PHPStan treats as possibly-undefined after a foreach (variable.undefined). Previously the live `=== null` branch's `!== null` narrowing implicitly re-established $file; once that branch became dead, the masking disappeared and the warning surfaced — it was correct, not a regression. Building the reflection inside the loop and returning from it keeps $file as the live loop variable and drops the dead null-check entirely, so both ignores go away with no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…instead of building reflection inside the loop Move the class reflection back out of the foreach loop and drop the now-dead null check, as requested in review. To keep the post-loop `$file` reference defined without an `@phpstan-ignore`, capture it into `$fetchedFile` (initialized before the loop, mirroring `$fetchedClassNode`). Its definedness is then proven by the same non-empty-array narrowing that makes the removed `=== null` check dead, rather than relying on the loop value variable, which PHPStan treats as possibly-undefined after a foreach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
When a variable was assigned inside one branch of an
ifand then the samecondition was re-checked later, PHPStan failed to re-narrow the variable if its
declared type was
mixed:With
@param int $valuethe same code correctly inferrednull. The variable'stype before the assignment was decisive, which it should not be.
Changes
src/Analyser/MutatingScope.php—createConditionalExpressions()no longerdrops a variable from the candidate set when the merged type equals the other
branch's type; it records it in
$guardsToExcludeand skips it only duringtype-guard selection. The variable stays available as a conditional target.
src/Analyser/ExprHandler/FuncCallHandler.php— removed the now-redundant$parametersAcceptor !== nullcondition (always true once$functionReflection !== nullholds, a relationship the sharper analysis nowtracks).
src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php— added
@phpstan-ignore identical.alwaysFalse/variable.undefinedin theclass branch where narrowing the single-element
$filesarray to a non-emptytype now proves a defensive null check is dead and that the foreach value
variable is always defined (mirroring the pre-existing
@phpstan-ignore variable.undefinedin the function branch).tests/PHPStan/Analyser/nsrt/bug-5051.php,tests/PHPStan/Analyser/nsrt/bug-8467b.php,tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php,tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php.Root cause
Conditional-expression holders record "when guard X has type T, variable Y has
type U", which is how a repeated condition re-narrows a variable. When merging
two branches,
createConditionalExpressions()built the candidate set$newVariableTypesfrom the our-branch types and then removed every variablewhose merged type equalled the other branch's type. That equality holds
exactly when the our-branch type is a subtype of the other branch's type, i.e.
the union absorbed it —
null|mixed === mixed,5|(int|string) === int|string,Foo|object === object, etc. The removal conflated two distinct roles: such avariable is indeed a poor guard (asserting its narrowed type later would not
reliably select the branch), but it is a perfectly valid conditional target.
Removing it from the shared set therefore lost the target relationship and the
later identical condition had nothing to fire. The fix splits the two roles:
the variable is excluded from guard selection only, keeping it as a target.
Test
tests/PHPStan/Analyser/nsrt/bug-7948.phpreproduces the report and alsocovers analogous absorption cases: a
mixedvalue (the report), a value typedint|stringreassigned to the subtype5, and the negated-condition(
!is_array(...)) variant. All assert the narrowed type.were tightened because the same mechanism now yields more precise types, and
make phpstanis green.Fixes phpstan/phpstan#7948