From 7e707fffc650c97d482347aec210b3a0b796fa17 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 15 Jul 2026 04:31:07 +0900 Subject: [PATCH 1/2] Narrow iterated expression to non-empty inside foreach body regardless 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 https://github.com/phpstan/phpstan/issues/13312 --- src/Analyser/NodeScopeResolver.php | 11 ++++-- .../Analyser/Bug13312NoPolluteTest.php | 36 ++++++++++++++++++ .../Analyser/bug-13312-no-pollute.neon | 2 + .../Analyser/data/bug-13312-no-pollute.php | 37 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13312.php | 18 +++++++++ .../Variables/DefinedVariableRuleTest.php | 4 -- 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Analyser/Bug13312NoPolluteTest.php create mode 100644 tests/PHPStan/Analyser/bug-13312-no-pollute.neon create mode 100644 tests/PHPStan/Analyser/data/bug-13312-no-pollute.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 72f087b400d..f3904f0274c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1453,6 +1453,11 @@ public function processStmtNode( $stmt->expr, new Array_([]), ); + // Inside the loop body the iterated expression is provably non-empty, so + // narrow it (list -> non-empty-list, array -> non-empty-array) at body entry + // from the pre-loop scope. This is independent of polluteScopeWithAlwaysIterableForeach, + // which only controls what leaks into the after-loop scope. + $nonEmptyIterateeScope = $scope->filterByTruthyValue($arrayComparisonExpr); $this->callNodeCallback($nodeCallback, new InForeachNode($stmt), $scope, $storage); $originalScope = $scope; $bodyScope = $scope; @@ -1475,7 +1480,7 @@ public function processStmtNode( if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; + $originalScope = $nonEmptyIterateeScope; $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; @@ -1486,7 +1491,7 @@ public function processStmtNode( $count = 0; do { $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage->duplicate(); $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); @@ -1506,7 +1511,7 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; diff --git a/tests/PHPStan/Analyser/Bug13312NoPolluteTest.php b/tests/PHPStan/Analyser/Bug13312NoPolluteTest.php new file mode 100644 index 00000000000..43f89c0d887 --- /dev/null +++ b/tests/PHPStan/Analyser/Bug13312NoPolluteTest.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-13312-no-pollute.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/bug-13312-no-pollute.neon b/tests/PHPStan/Analyser/bug-13312-no-pollute.neon new file mode 100644 index 00000000000..3ee516d3be6 --- /dev/null +++ b/tests/PHPStan/Analyser/bug-13312-no-pollute.neon @@ -0,0 +1,2 @@ +parameters: + polluteScopeWithAlwaysIterableForeach: false diff --git a/tests/PHPStan/Analyser/data/bug-13312-no-pollute.php b/tests/PHPStan/Analyser/data/bug-13312-no-pollute.php new file mode 100644 index 00000000000..2926f972972 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-13312-no-pollute.php @@ -0,0 +1,37 @@ + $arr */ +function foo(array $arr): void { + assertType('list', $arr); + foreach ($arr as $v) { + assertType('non-empty-list', $arr); + } + assertType('list', $arr); + + for ($i = 0; $i < count($arr); ++$i) { + assertType('non-empty-list', $arr); + } + assertType('list', $arr); +} + +/** @param array $arr */ +function fooStringKeyed(array $arr): void { + assertType('array', $arr); + foreach ($arr as $v) { + assertType('non-empty-array', $arr); + } + assertType('array', $arr); +} + +/** @param list $arr */ +function fooReassign(array $arr): void { + foreach ($arr as $v) { + $arr = []; + assertType('array{}', $arr); + } + assertType('array{}', $arr); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13312.php b/tests/PHPStan/Analyser/nsrt/bug-13312.php index 3a7ab22873c..9a47594a06f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13312.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13312.php @@ -32,6 +32,24 @@ function foo(array $arr): void { } +/** @param array $arr */ +function fooStringKeyed(array $arr): void { + assertType('array', $arr); + foreach ($arr as $v) { + assertType('non-empty-array', $arr); + } + assertType('array', $arr); +} + +/** @param list $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) { diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index ca236634015..a4add283d77 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -961,10 +961,6 @@ public function testBug8467c(): void 'Variable $v might not be defined.', 16, ], - [ - 'Variable $v might not be defined.', - 18, - ], ]); } From 8aa4092f44cec20fd6d1274dd2aa1fe0df2d7a6e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 23 Jul 2026 20:47:19 +0900 Subject: [PATCH 2/2] Fix self-analysis errors surfaced by the foreach non-empty narrowing 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|null property gain an explicit null guard throwing ShouldNotHappenException, matching the file's existing impossible-state style. --- src/Command/AnalyseCommand.php | 4 ++-- .../OptimizedDirectorySourceLocator.php | 6 +++--- src/Type/Constant/ConstantArrayType.php | 15 ++++++++++++--- src/Type/UnionType.php | 4 ++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index 5b3be09c6b5..64d55f8da44 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -477,8 +477,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($generateBaselineFile !== null) { $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput(), $analysisResult->getProcessedFiles()); if (count($internalErrorsTuples) > 0) { - foreach ($internalErrorsTuples as [$internalError]) { - $inceptionResult->getStdOutput()->writeLineFormatted($internalError->getMessage()); + foreach ($internalErrorsTuples as [$error]) { + $inceptionResult->getStdOutput()->writeLineFormatted($error->getMessage()); $inceptionResult->getStdOutput()->writeLineFormatted(''); } diff --git a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php index b83b5a35a17..8c4b70d8050 100644 --- a/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/OptimizedDirectorySourceLocator.php @@ -134,8 +134,8 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): if ($identifier->isClass()) { $fetchedClassNode = null; $fetchedFile = null; - foreach ($files as $file) { - $fetchedClassNodes = $this->fileNodesFetcher->fetchNodes($file)->getClassNodes(); + foreach ($files as $sourceFile) { + $fetchedClassNodes = $this->fileNodesFetcher->fetchNodes($sourceFile)->getClassNodes(); if (!array_key_exists($identifierName, $fetchedClassNodes)) { return null; @@ -143,7 +143,7 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): /** @var FetchedNode $fetchedClassNode */ $fetchedClassNode = current($fetchedClassNodes[$identifierName]); - $fetchedFile = $file; + $fetchedFile = $sourceFile; } [$reflectionCacheKey, $variableCacheKey] = $this->getCacheKeys($fetchedFile, $identifier); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 7eca2eaa587..60372869aec 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -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)) { @@ -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); } diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index 011cdec9389..b8dca23375f 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -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 $j) { foreach ($commonReasons as $reason) { - $decorated[] = sprintf('Type #%d from the union: %s', $i + 1, $reason); + $decorated[] = sprintf('Type #%d from the union: %s', $j + 1, $reason); } } $result = new AcceptsResult($result->result, $decorated);