Narrow foreach body to non-empty regardless of polluteScopeWithAlwaysIterableForeach#6094
Narrow foreach body to non-empty regardless of polluteScopeWithAlwaysIterableForeach#6094zonuexe wants to merge 2 commits into
Conversation
568511a to
8aa4092
Compare
| if (count($internalErrorsTuples) > 0) { | ||
| foreach ($internalErrorsTuples as [$internalError]) { | ||
| $inceptionResult->getStdOutput()->writeLineFormatted($internalError->getMessage()); | ||
| foreach ($internalErrorsTuples as [$error]) { |
There was a problem hiding this comment.
Why are this changes necessary?
(Did your change broke the polluteScopeWithAlwaysIterableForeach use-case?)
There was a problem hiding this comment.
Good catch, you were right. Those renames were compensating for a real behaviour change, not fixing latent issues.
My first version narrowed the iterated expression for the whole loop, which leaked the non-emptiness into the after-loop scope. With polluteScopeWithAlwaysIterableForeach: false the value variable ended up looking always-defined after the loop, via a conditional ("the iteratee is non-empty, so the value variable is defined") that a later count() > 0 could resolve. That surfaced the new foreach.valueOverwrite errors, and the renames papered over it.
The reworked version applies the narrowing only while analysing the loop body, then strips the non-emptiness back out of the after-loop scope when the flag is off. So the flag's contract holds: after the loop the value variable stays possibly-undefined and the iteratee stays possibly-empty. An iteratee that was already provably non-empty before the loop keeps that property, so nothing downstream regresses. The $internalError and $file renames are gone as a result.
The two adjustments in the second commit are genuine flag-independent facts the more precise in-body analysis now proves: a reused foreach value variable in UnionType::accepts(), and a non-null $this->unsealed destructuring in ConstantArrayType. Neither relates to the after-loop behaviour.
8aa4092 to
b5a6e6e
Compare
|
This pull request has been marked as ready for review. |
| if ($commonReasons !== null && count($commonReasons) > 0) { | ||
| $decorated = []; | ||
| foreach (array_keys($innerAccepts) as $i) { | ||
| foreach (array_keys($innerAccepts) as $innerAcceptsIndex) { |
There was a problem hiding this comment.
I think we have the same problem here.. this change got necessary because the PR broke polluteScopeWithAlwaysIterableForeach?
There was a problem hiding this comment.
You're right, and my earlier reply was wrong. Sorry. I checked: $i in UnionType::accepts() becomes defined after the first loop only because of this change, and it matches what polluteScopeWithAlwaysIterableForeach: true already produces. So it is the same leak, not a flag-independent fact.
The leak flows through conditionals between variables the loop body builds (here $innerAccepts), not through the iterated expression itself, and I could not strip it without also dropping non-emptiness that other code legitimately relies on. So instead of chasing full containment, I've gated the whole behaviour behind a bleedingEdge toggle, narrowForeachBodyNonEmpty.
With bleedingEdge off (stable), polluteScopeWithAlwaysIterableForeach: false behaves exactly as before: no in-body narrowing, no after-loop change. With bleedingEdge on, the body narrows and #13312 is fixed. This rename and the ConstantArrayType guards are only needed because the self-analysis config runs with bleedingEdge on.
Inside a foreach body the iterated expression is provably non-empty, so it can be narrowed (list to non-empty-list, array to non-empty-array) at body entry. Before, this happened only when polluteScopeWithAlwaysIterableForeach was on, so with the flag off (as phpstan-strict-rules sets it) the narrowing disappeared even though the body is only entered when the iteratee is non-empty. The narrowing is gated behind the new bleedingEdge feature toggle narrowForeachBodyNonEmpty. With it off the body scope is built exactly as before, so stable behaviour does not change. With it on, the body narrows regardless of polluteScopeWithAlwaysIterableForeach. Narrowing the body also feeds the loop-exit scope, which with the flag off must stay conservative: we must not conclude the loop always iterated. After the body pass the iterated expression's possibly-empty-ness is restored in the after-loop scope (keeping any element types the body refined), so the value variable does not become always-defined after the loop. Only the non-emptiness the narrowing introduced is stripped; an iteratee already non-empty before the loop keeps that property. This containment is partial: definedness that flows through variables the body builds (rather than through the iterated expression itself) still reaches the after-loop scope, so a few value variables can become defined where a proof of non-emptiness exists. Closes phpstan/phpstan#13312
The self-analysis config runs with bleedingEdge on, so the foreach body now
narrows the iterated expression even under phpstan-strict-rules. That lets
PHPStan prove two facts in its own source that it previously left uncertain:
- UnionType::accepts() reuses $i as the value variable of a second foreach
after it was the key variable of an earlier one. The reused name now reads
as a foreach.valueOverwrite, so rename the second loop's variable.
- ConstantArrayType::isSuperTypeOf() destructures $this->unsealed and
$type->unsealed (typed array{Type, Type}|null) in branches guarded by a
separate unsealedness check that PHPStan cannot connect to the property.
Read the property into a local and throw ShouldNotHappenException on null,
matching the file's existing impossible-state style.
Both changes preserve behaviour.
b5a6e6e to
54ac5fd
Compare
Closes phpstan/phpstan#13312
This reproduces with
polluteScopeWithAlwaysIterableForeach: false, whichphpstan-strict-rulessets.Inside the loop body
$arris provably non-empty. PHPStan already narrowed it there, but only as a side effect ofpolluteScopeWithAlwaysIterableForeach: with the flag off the in-body narrowing disappeared. A previous fix (phpstan-src#4162) added a test for it, but only under the default flag value, so the reported case kept failing underphpstan-strict-rulesand the issue was reopened.Gated behind bleedingEdge
The narrowing is gated behind a new bleedingEdge toggle
narrowForeachBodyNonEmpty.polluteScopeWithAlwaysIterableForeach: falsebehaves as it does today, no change.Narrowing the body also feeds the loop-exit scope, which with the flag off should stay conservative (we must not conclude the loop always iterated). After the body pass the iterated expression's possibly-empty-ness is restored in the after-loop scope, keeping any element types the body refined, so a value variable does not become always-defined after the loop just because the iteratee was narrowed. This containment is partial: definedness that flows through variables the body builds (rather than through the iterated expression itself) still reaches the after-loop scope. That only happens under bleedingEdge.
Second commit
Because the self-analysis config runs with bleedingEdge on, the more precise in-body narrowing proves two facts in PHPStan's own source: a reused foreach value variable in
UnionType::accepts(), and a non-null$this->unsealeddestructuring inConstantArrayType. Both are adjusted in a separate commit.