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 narrows it there, but only by accident: the narrowing came from polluteScopeWithAlwaysIterableForeach, a flag whose real job is to control what leaks into the scope after the loop, not what the loop body itself sees. With the flag off, the in-body narrowing disappeared along with it.

A previous fix (phpstan-src#4162) added a test for this narrowing, but only under the default polluteScopeWithAlwaysIterableForeach: true. Under phpstan-strict-rules, which sets it to false, the bug remained and the issue got reopened.

This PR narrows the iterated expression to non-empty at foreach-body entry unconditionally, so the flag now only affects the after-loop scope, as documented.

Note

DefinedVariableRuleTest::testBug8467c loses one of its two Variable $v might not be defined. expectations. $v is assigned in one foreach, then read inside a second, sibling foreach over an array only populated by the first loop's body — proving that second array non-empty also proves $v defined. Confirmed with \PHPStan\dumpType() that $v's type was always correctly int; the removed expectation was a false positive.

zonuexe added 2 commits July 23, 2026 19:35
…s of polluteScopeWithAlwaysIterableForeach

The in-body narrowing of the iterated expression (list -> non-empty-list,
array -> non-empty-array) was an accidental side effect of the
polluteScopeWithAlwaysIterableForeach scope-pollution mechanism: it only
happened when the flag was true. With the flag set to false (as
phpstan-strict-rules does), the narrowing silently disappeared even though
the loop body is provably entered only when the iteratee is non-empty.

Apply the non-empty narrowing at foreach-body entry from the pre-loop scope
unconditionally, mirroring the previous flag-true behaviour exactly. The flag
now only controls what leaks into the after-loop scope, which is its
documented purpose.

Closes phpstan/phpstan#13312
The unconditional non-empty narrowing makes phpstan-src's own foreach bodies
provably entered, which surfaces six latent issues under phpstan-strict-rules:

- foreach.valueOverwrite: three inner loops whose value variable now shadows a
  definitely-defined outer variable are renamed ($internalError -> $error,
  $file -> $sourceFile, $i -> $j).
- offsetAccess.nonArray: three [$k, $v] = $this->unsealed destructurings on a
  list<Type>|null property gain an explicit null guard throwing
  ShouldNotHappenException, matching the file's existing impossible-state style.
@zonuexe
zonuexe force-pushed the foreach-non-empty-narrowing branch 2 times, most recently from 568511a to 8aa4092 Compare July 23, 2026 13:07
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

1 participant