Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435
Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
Conversation
- Fixed processAlwaysIterableForeachScopeWithoutPollute using stale conditional expressions from before the foreach body, which caused variable aliases (e.g. $current = $initial) to persist even after reassignment inside the foreach - Changed to use $finalScope->conditionalExpressions which properly reflects invalidated aliases from foreach body assignments - New regression test in tests/PHPStan/Analyser/data/bug-14446.php Closes phpstan/phpstan#14446
VincentLanglet
left a comment
There was a problem hiding this comment.
This produce the unwanted error
Foreach overwrites $key with its key variable.
with the code
if (is_array($value) && $value !== []) {
$hasOnlyStringKey = true;
foreach (array_keys($value) as $key) {
if (is_int($key)) {
$hasOnlyStringKey = false;
break;
}
}
if ($hasOnlyStringKey) {
$keyTypes = [];
$valueTypes = [];
foreach ($value as $key => $element) {
$keyType = $scope->getTypeFromValue($key);
$keyStringTypes = $keyType->getConstantStrings();
if (count($keyStringTypes) !== 1) {
throw new ShouldNotHappenException();
}
$keyTypes[] = $keyStringTypes[0];
$valueTypes[] = $this->generalizeTypeFromValue($scope, $element);
}
return ConstantArrayTypeBuilder::createFromConstantArray(
new ConstantArrayType($keyTypes, $valueTypes),
)->getArray();
}
return new ArrayType(
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_keys($value))),
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_values($value))),
);
}
Since $key is only defined in the foreach loop the error shouldn't be reported with pollute false
…ableForeachScopeWithoutPollute When using $finalScope->conditionalExpressions, conditional expressions referencing variables only defined inside the foreach body (like $key) could leak into the outer scope. When those conditional expressions fired (e.g. inside an if block), the foreach variable would be re-introduced with Yes certainty, causing false "Foreach overwrites $key" errors. Filter conditional expressions to only keep those where both the target expression and all condition expressions reference variables that existed before the foreach loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's what I did: Problem: The PR's change to use Fix (
This preserves the original bug 14446 fix (stale conditional expressions from Test: Added |
VincentLanglet
left a comment
There was a problem hiding this comment.
This now produce
------ ----------------------------------------------------------------------------------------------------
Line src\Analyser\NodeScopeResolver.php
------ ----------------------------------------------------------------------------------------------------
437 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
451 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
471 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
476 Parameter #1 $node of class PHPStan\Node\ExecutionEndNode constructor expects PhpParser\Node\Stmt,
PhpParser\Node given.
🪪 argument.type
------ ----------------------------------------------------------------------------------------------------
So I assume your fix is not correct
…copeWithoutPollute The previous approach (using $finalScope->conditionalExpressions filtered to $this variables) lost valid conditional expressions that existed in $this but not in $finalScope, causing false positives like "Call to an undefined method PhpParser\Node::getReturnType()". The correct approach: start from $this->conditionalExpressions (pre-foreach) and remove only entries where a condition variable's type changed between $this and $finalScope, indicating it was reassigned in the foreach body and the conditional expression is stale. When a type change is detected, the entire conditional expression key is removed (not individual holders) to avoid partial/inconsistent state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed the fix. Here's a summary of the change: Problem: The previous approach used Fix: Start from Verification:
|
Summary
When
polluteScopeWithAlwaysIterableForeachisfalse(enabled by strict rules), PHPStan incorrectly narrowed variables that were aliased via assignment. For example,$current = $initialfollowed byif (!$current)would incorrectly narrow$initialtofalseafter aforeachloop reassigned$current, because the alias relationship was not properly invalidated.Changes
src/Analyser/MutatingScope.php: InprocessAlwaysIterableForeachScopeWithoutPollute(), changed from using$this->conditionalExpressions(pre-foreach) to$finalScope->conditionalExpressions(post-foreach body). This ensures that conditional expressions invalidated by assignments inside the foreach body (like$current = false) are not incorrectly preserved.tests/PHPStan/Analyser/Bug14446Test.phpwith test data and config.Root cause
processAlwaysIterableForeachScopeWithoutPollute()takes expression types from$finalScope(the scope after the foreach body runs) but was using conditional expressions from$this(the scope before the foreach). When$current = $initialcreates a conditional expression linking the two variables, and then$current = falseinside a foreach invalidates that link in$finalScope, the method was still using the old (stale) conditional expressions from$this. This caused$initialto be incorrectly narrowed tofalsewhen$currentwasfalse(from the foreach reassignment), even though the alias was no longer valid.In contrast, when
polluteScopeWithAlwaysIterableForeachistrue, thegeneralizeWith()path widens the type of$currentback tobool, so the conditional expression never fires — masking the issue.Test
Added
tests/PHPStan/Analyser/Bug14446Test.phpwhich usespolluteScopeWithAlwaysIterableForeach: falseconfig and verifies that$initialremainsboolthroughout the function — at the top of the while loop, inside theif (!$current)block, and after the while loop exits.Fixes phpstan/phpstan#14446