Skip to content

Narrow foreach body to non-empty regardless of polluteScopeWithAlwaysIterableForeach#6094

Open
zonuexe wants to merge 2 commits into
phpstan:2.2.xfrom
zonuexe:foreach-non-empty-narrowing
Open

Narrow foreach body to non-empty regardless of polluteScopeWithAlwaysIterableForeach#6094
zonuexe wants to merge 2 commits into
phpstan:2.2.xfrom
zonuexe:foreach-non-empty-narrowing

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#13312

/** @param list<mixed> $arr */
function foo(array $arr): void {
	foreach ($arr as $v) {
		bar($arr);
	}
}

/** @param non-empty-list<mixed> $arr */
function bar(array $arr): void {}

This reproduces with polluteScopeWithAlwaysIterableForeach: false, which phpstan-strict-rules sets.

Inside the loop body $arr is provably non-empty. PHPStan already narrowed it there, but only as a side effect of polluteScopeWithAlwaysIterableForeach: 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 under phpstan-strict-rules and the issue was reopened.

Gated behind bleedingEdge

The narrowing is gated behind a new bleedingEdge toggle narrowForeachBodyNonEmpty.

  • stable (toggle off): the body scope is built exactly as before. polluteScopeWithAlwaysIterableForeach: false behaves as it does today, no change.
  • bleedingEdge (toggle on): the foreach body narrows the iterated expression regardless of the flag, so #13312 is fixed.

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->unsealed destructuring in ConstantArrayType. Both are adjusted in a separate commit.

@zonuexe
zonuexe force-pushed the foreach-non-empty-narrowing branch 2 times, most recently from 568511a to 8aa4092 Compare July 23, 2026 13:07
Comment thread src/Command/AnalyseCommand.php Outdated
if (count($internalErrorsTuples) > 0) {
foreach ($internalErrorsTuples as [$internalError]) {
$inceptionResult->getStdOutput()->writeLineFormatted($internalError->getMessage());
foreach ($internalErrorsTuples as [$error]) {

@staabm staabm Jul 25, 2026

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.

Why are this changes necessary?
(Did your change broke the polluteScopeWithAlwaysIterableForeach use-case?)

@zonuexe zonuexe Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@zonuexe
zonuexe marked this pull request as draft July 25, 2026 14:12
@zonuexe
zonuexe force-pushed the foreach-non-empty-narrowing branch from 8aa4092 to b5a6e6e Compare July 25, 2026 14:13
@zonuexe
zonuexe marked this pull request as ready for review July 25, 2026 14:33
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

Comment thread src/Type/UnionType.php
if ($commonReasons !== null && count($commonReasons) > 0) {
$decorated = [];
foreach (array_keys($innerAccepts) as $i) {
foreach (array_keys($innerAccepts) as $innerAcceptsIndex) {

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 have the same problem here.. this change got necessary because the PR broke polluteScopeWithAlwaysIterableForeach?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

zonuexe added 2 commits July 26, 2026 02:33
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.
@zonuexe
zonuexe force-pushed the foreach-non-empty-narrowing branch from b5a6e6e to 54ac5fd Compare July 25, 2026 17:34
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.

Narrow list to non-empty-list inside foreach

3 participants