Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ parameters:
rawMessageInBaseline: true
reportNestedTooWideType: false # tmp
assignToByRefForeachExpr: true
narrowForeachBodyNonEmpty: true
curlSetOptArrayTypes: true
magicDirInInclude: true
checkDateIntervalConstructor: true
Expand Down
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ parameters:
rawMessageInBaseline: false
reportNestedTooWideType: false
assignToByRefForeachExpr: false
narrowForeachBodyNonEmpty: false
curlSetOptArrayTypes: false
magicDirInInclude: false
checkDateIntervalConstructor: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ parametersSchema:
rawMessageInBaseline: bool()
reportNestedTooWideType: bool()
assignToByRefForeachExpr: bool()
narrowForeachBodyNonEmpty: bool()
curlSetOptArrayTypes: bool()
magicDirInInclude: bool()
checkDateIntervalConstructor: bool()
Expand Down
52 changes: 49 additions & 3 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\FileTypeMapper;
Expand Down Expand Up @@ -232,6 +233,8 @@
private readonly bool $polluteScopeWithLoopInitialAssignments,
#[AutowiredParameter]
private readonly bool $polluteScopeWithAlwaysIterableForeach,
#[AutowiredParameter(ref: '%featureToggles.narrowForeachBodyNonEmpty%')]
private readonly bool $narrowForeachBodyNonEmpty,
#[AutowiredParameter]
private readonly bool $polluteScopeWithBlock,
#[AutowiredParameter]
Expand Down Expand Up @@ -1453,6 +1456,16 @@
$stmt->expr,
new Array_([]),
);
// Inside the loop body the iterated expression is non-empty, so narrow it
// (list to non-empty-list, array to non-empty-array) at body entry. Under
// narrowForeachBodyNonEmpty this happens even with
// polluteScopeWithAlwaysIterableForeach off; the code below then strips the
// narrowing back out of the after-loop scope, so the "loop always iterated"
// assumption never leaks past the loop. With the toggle off the body scope
// matches the old behaviour exactly.
$bodyIterateeScope = $this->narrowForeachBodyNonEmpty || $this->polluteScopeWithAlwaysIterableForeach
? $scope->filterByTruthyValue($arrayComparisonExpr)
: $scope;
$this->callNodeCallback($nodeCallback, new InForeachNode($stmt), $scope, $storage);
$originalScope = $scope;
$bodyScope = $scope;
Expand All @@ -1475,7 +1488,7 @@
if ($context->isTopLevel()) {
$storage = $originalStorage->duplicate();

$originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope;

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 extracting $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope into a new variable and therefore removing repetative calls to $scope->filterByTruthyValue() is likely a speed optimization worth a separate PR

$originalScope = $bodyIterateeScope;
$unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context);
if ($unrolledResult !== null) {
$bodyScope = $unrolledResult['bodyScope'];
Expand All @@ -1486,7 +1499,7 @@
$count = 0;
do {
$prevScope = $bodyScope;
$bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope);
$bodyScope = $bodyScope->mergeWith($bodyIterateeScope);
$storage = $originalStorage->duplicate();
$bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback);
$bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
Expand All @@ -1506,7 +1519,7 @@
}
}

$bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope);
$bodyScope = $bodyScope->mergeWith($bodyIterateeScope);
$storage = $originalStorage;
$bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback);
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
Expand Down Expand Up @@ -1654,6 +1667,39 @@
}

$isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce();

$iterateeCertainty = $finalScope->hasExpressionType($stmt->expr);
if (
$this->narrowForeachBodyNonEmpty
&& !$this->polluteScopeWithAlwaysIterableForeach
&& !$iterateeCertainty->no()

Check warning on line 1675 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $this->narrowForeachBodyNonEmpty && !$this->polluteScopeWithAlwaysIterableForeach - && !$iterateeCertainty->no() + && $iterateeCertainty->yes() && !$isIterableAtLeastOnce->yes() ) { // Keep the in-body narrowing out of the after-loop scope. With

Check warning on line 1675 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $this->narrowForeachBodyNonEmpty && !$this->polluteScopeWithAlwaysIterableForeach - && !$iterateeCertainty->no() + && $iterateeCertainty->yes() && !$isIterableAtLeastOnce->yes() ) { // Keep the in-body narrowing out of the after-loop scope. With
&& !$isIterableAtLeastOnce->yes()

Check warning on line 1676 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $this->narrowForeachBodyNonEmpty && !$this->polluteScopeWithAlwaysIterableForeach && !$iterateeCertainty->no() - && !$isIterableAtLeastOnce->yes() + && $isIterableAtLeastOnce->no() ) { // Keep the in-body narrowing out of the after-loop scope. With // polluteScopeWithAlwaysIterableForeach off we cannot assume the loop ran,
) {
// Keep the in-body narrowing out of the after-loop scope. With
// polluteScopeWithAlwaysIterableForeach off we cannot assume the loop ran,
// so restore the possibly-empty-ness of the iterated expression (keeping any
// element types the body refined). The loop-exit merge then builds the same
// conservative scope it would without the narrowing, and does not treat the
// value variable as always defined after the loop.
//
// Strip only the non-emptiness the narrowing added. If the iteratee was
// already non-empty before the loop ($isIterableAtLeastOnce->yes()), that
// holds regardless of the loop, so keep it. A literal like
// `foreach ([1, 2] as $v)` is not a tracked expression, so skip it.
Comment on lines +1678 to +1688

@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.

added comments feel too verbose, should be condensed a bit

$finalIterateeType = $finalScope->getType($stmt->expr);
if ($finalIterateeType->isArray()->yes()) {

Check warning on line 1690 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // holds regardless of the loop, so keep it. A literal like // `foreach ([1, 2] as $v)` is not a tracked expression, so skip it. $finalIterateeType = $finalScope->getType($stmt->expr); - if ($finalIterateeType->isArray()->yes()) { + if (!$finalIterateeType->isArray()->no()) { $finalIterateeNativeType = $finalScope->getNativeType($stmt->expr); $finalScope = $finalScope->specifyExpressionType( $stmt->expr,

Check warning on line 1690 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // holds regardless of the loop, so keep it. A literal like // `foreach ([1, 2] as $v)` is not a tracked expression, so skip it. $finalIterateeType = $finalScope->getType($stmt->expr); - if ($finalIterateeType->isArray()->yes()) { + if (!$finalIterateeType->isArray()->no()) { $finalIterateeNativeType = $finalScope->getNativeType($stmt->expr); $finalScope = $finalScope->specifyExpressionType( $stmt->expr,
$finalIterateeNativeType = $finalScope->getNativeType($stmt->expr);
$finalScope = $finalScope->specifyExpressionType(
$stmt->expr,
TypeCombinator::union($finalIterateeType, new ConstantArrayType([], [])),
$finalIterateeNativeType->isArray()->yes()

Check warning on line 1695 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $finalScope = $finalScope->specifyExpressionType( $stmt->expr, TypeCombinator::union($finalIterateeType, new ConstantArrayType([], [])), - $finalIterateeNativeType->isArray()->yes() + !$finalIterateeNativeType->isArray()->no() ? TypeCombinator::union($finalIterateeNativeType, new ConstantArrayType([], [])) : $finalIterateeNativeType, $iterateeCertainty,

Check warning on line 1695 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $finalScope = $finalScope->specifyExpressionType( $stmt->expr, TypeCombinator::union($finalIterateeType, new ConstantArrayType([], [])), - $finalIterateeNativeType->isArray()->yes() + !$finalIterateeNativeType->isArray()->no() ? TypeCombinator::union($finalIterateeNativeType, new ConstantArrayType([], [])) : $finalIterateeNativeType, $iterateeCertainty,
? TypeCombinator::union($finalIterateeNativeType, new ConstantArrayType([], []))
: $finalIterateeNativeType,
$iterateeCertainty,
);
}
}

if ($isIterableAtLeastOnce->maybe() || $exprType->isIterable()->no()) {
$finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr(
new BinaryOp\Identical(
Expand Down
1 change: 1 addition & 0 deletions src/Testing/RuleTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
self::getContainer()->getByType(DeepNodeCloner::class),
$this->shouldPolluteScopeWithLoopInitialAssignments(),
$this->shouldPolluteScopeWithAlwaysIterableForeach(),
self::getContainer()->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
self::getContainer()->getParameter('polluteScopeWithBlock'),
[],
[],
Expand Down
1 change: 1 addition & 0 deletions src/Testing/TypeInferenceTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
self::getContainer()->getByType(DeepNodeCloner::class),
$container->getParameter('polluteScopeWithLoopInitialAssignments'),
$container->getParameter('polluteScopeWithAlwaysIterableForeach'),
$container->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
$container->getParameter('polluteScopeWithBlock'),
static::getEarlyTerminatingMethodCalls(),
static::getEarlyTerminatingFunctionCalls(),
Expand Down
15 changes: 12 additions & 3 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,11 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
continue;
}

[$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
$thisUnsealed = $this->unsealed;
if ($thisUnsealed === null) {
throw new ShouldNotHappenException();
}
[$thisUnsealedKey, $thisUnsealedValue] = $thisUnsealed;
$keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey);
if ($keyCheck->no()) {
if ($type->isOptionalKey($i)) {
Expand All @@ -835,8 +839,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
if ($thisUnsealedness->no()) {
$results[] = IsSuperTypeOfResult::createMaybe();
} else {
[$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
[$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed;
$thisUnsealed = $this->unsealed;
$typeUnsealed = $type->unsealed;
if ($thisUnsealed === null || $typeUnsealed === null) {
throw new ShouldNotHappenException();
}
[$thisUnsealedKey, $thisUnsealedValue] = $thisUnsealed;
[$typeUnsealedKey, $typeUnsealedValue] = $typeUnsealed;
$results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey);
$results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue);
}
Expand Down
4 changes: 2 additions & 2 deletions src/Type/UnionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
}
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.

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.

please remove unrelated changes in

  • src/Type/UnionType.php
  • src/Type/Constant/ConstantArrayType.php

foreach ($commonReasons as $reason) {
$decorated[] = sprintf('Type #%d from the union: %s', $i + 1, $reason);
$decorated[] = sprintf('Type #%d from the union: %s', $innerAcceptsIndex + 1, $reason);
}
}
$result = new AcceptsResult($result->result, $decorated);
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/AnalyserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@ private function createAnalyser(): Analyser
$container->getByType(DeepNodeCloner::class),
false,
true,
false,
true,
[],
[],
Expand Down
36 changes: 36 additions & 0 deletions tests/PHPStan/Analyser/Bug13312NoPolluteTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class Bug13312NoPolluteTest extends TypeInferenceTestCase
{

public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-13312-no-pollute.php');
}

/**
* @param mixed ...$args
*/
#[DataProvider('dataFileAsserts')]
public function testFileAsserts(
string $assertType,
string $file,
...$args,
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [
__DIR__ . '/bug-13312-no-pollute.neon',
];
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
self::getContainer()->getByType(DeepNodeCloner::class),
$this->shouldPolluteScopeWithLoopInitialAssignments(),
$this->shouldPolluteScopeWithAlwaysIterableForeach(),
self::getContainer()->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
self::getContainer()->getParameter('polluteScopeWithBlock'),
[],
[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
$container->getByType(DeepNodeCloner::class),
$container->getParameter('polluteScopeWithLoopInitialAssignments'),
$container->getParameter('polluteScopeWithAlwaysIterableForeach'),
$container->getParameter('featureToggles')['narrowForeachBodyNonEmpty'],
$container->getParameter('polluteScopeWithBlock'),
static::getEarlyTerminatingMethodCalls(),
static::getEarlyTerminatingFunctionCalls(),
Expand Down
4 changes: 4 additions & 0 deletions tests/PHPStan/Analyser/bug-13312-no-pollute.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
polluteScopeWithAlwaysIterableForeach: false
featureToggles:
narrowForeachBodyNonEmpty: true
Comment on lines +2 to +4

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.

do we need more tests with other combinations of this toggles?

37 changes: 37 additions & 0 deletions tests/PHPStan/Analyser/data/bug-13312-no-pollute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types = 1);

namespace Bug13312NoPollute;

use function PHPStan\Testing\assertType;

/** @param list<mixed> $arr */
function foo(array $arr): void {
assertType('list<mixed>', $arr);
foreach ($arr as $v) {
assertType('non-empty-list<mixed>', $arr);
}
assertType('list<mixed>', $arr);

for ($i = 0; $i < count($arr); ++$i) {
assertType('non-empty-list<mixed>', $arr);
}
assertType('list<mixed>', $arr);
}

/** @param array<string, int> $arr */
function fooStringKeyed(array $arr): void {
assertType('array<string, int>', $arr);
foreach ($arr as $v) {
assertType('non-empty-array<string, int>', $arr);
}
assertType('array<string, int>', $arr);
}

/** @param list<mixed> $arr */
function fooReassign(array $arr): void {
foreach ($arr as $v) {
$arr = [];
assertType('array{}', $arr);
}
assertType('array{}', $arr);
}
18 changes: 18 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13312.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ function foo(array $arr): void {
}


/** @param array<string, int> $arr */
function fooStringKeyed(array $arr): void {
assertType('array<string, int>', $arr);
foreach ($arr as $v) {
assertType('non-empty-array<string, int>', $arr);
}
assertType('array<string, int>', $arr);
}

/** @param list<mixed> $arr */
function fooReassign(array $arr): void {
foreach ($arr as $v) {
$arr = [];
assertType('array{}', $arr);
}
assertType('array{}', $arr);
}

function fooBar(mixed $mixed): void {
assertType('mixed', $mixed);
foreach ($mixed as $v) {
Expand Down
4 changes: 0 additions & 4 deletions tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -961,10 +961,6 @@ public function testBug8467c(): void
'Variable $v might not be defined.',
16,
],
[
'Variable $v might not be defined.',
18,
],
]);
}

Expand Down
Loading