Narrow array element type after a keyless foreach whose body guards each element with an early exit#5877
Conversation
… each element with an early exit - Generalize the post-loop array rewrite in NodeScopeResolver::processStmtNode() so it also fires for `foreach ($a as $v)` (no key variable). Previously the rewrite required `$stmt->keyVar !== null`, so type guards like `if (!is_string($v)) return;` never narrowed the iterated array. - Track iterations through the original value expression (OriginalForeachValueExpr) when no key variable is present, instead of the original key expression, and read the narrowed element type directly from the value variable rather than from an ArrayDimFetch (which requires a key). - Reassigning the value variable inside the loop still invalidates the tracking expression, so narrowing is correctly skipped in that case; loops that `break` out are still excluded. - This also covers the analogous keyless constructs that share the same code path: `instanceof` guards, `throw`, by-reference value variables, list vs non-list arrays, and `continue` (which correctly does not narrow). - Update tests/PHPStan/Analyser/nsrt/bug-7076.php (noKeyVar): the value type is now narrowed even without a key variable. - Remove a now-provably-dead defensive `instanceof ClassConstFetch` re-check in MatchHandler: the pre-validation loop already guarantees it via `break 2`, which the sharper inference now understands.
ondrejmirtes
left a comment
There was a problem hiding this comment.
Fix the tests:
There was 1 failure:
- PHPStan\Build\RequiredPhpVersionCommentTest::testFixtureHasRequiredLintComment@/home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Build/../Analyser/nsrt/bug-5755.php with data ('/home/runner/work/phpstan-src...55.php')
Fixture uses union types on line 11 which requires PHP 8.0. Add a<?php // lint >= 8.0comment on the first line so the fixture is skipped on older PHP versions in CI.
Failed asserting that 0 is equal to 80000 or is greater than 80000.
/home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Build/RequiredPhpVersionCommentTest.php:101
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. The reviewer's requested change has been addressed. SummaryThe review pointed out a CI test failure: Fix: Added Verification:
The change was committed and pushed to the PR branch. |
ondrejmirtes
left a comment
There was a problem hiding this comment.
Would be nice to address the escaped mutants in CI in Infection with more tests
Infection flagged an escaped mutant on the foreach element-narrowing condition: the `$keyVarExpr !== null` operand of `($keyVarExpr !== null || $originalValueExpr !== null)` could be mutated without any test failing. The two operands only diverge when a key variable is present but the value is destructured (so the value var is not a simple `Variable` and `$originalValueExpr` is null) — a case no existing test exercised, since key narrowing then flows purely through the key path. Add a `foreach ($a as $k => [$v])` case that narrows the key via an early-exit guard, asserting the array is narrowed to `array<int, ...>`. The assertion fails when that operand is dropped, killing the mutant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. Here's what I did to address the review ("Would be nice to address the escaped mutants in CI in Infection with more tests"). InvestigationSince CI logs weren't accessible, I reproduced Infection's escaped-mutant detection locally via manual mutation testing: I built a coverage set of all 193 nsrt fixtures containing I tested 17 mutations across the changed conditions (coalesce swap, ($keyVarExpr !== null || $originalValueExpr !== null)
// ^^^^^^^^^^^^^^^^^ mutating this operand changed nothingThe two operands only diverge when The MatchHandler.php change only added comments (not mutable), and the dead-looking key-type init lines are bare method-call assignments Infection doesn't mutate — so neither produced escaped mutants. FixAdded a regression case to foreach ($a as $k => [$v]) {
if (!is_int($k)) {
return;
}
}
assertType('array<int, array{int}>', $a);I verified it kills the mutant (fails → Verification
|
Summary
When an array is iterated with a keyless
foreachand the loop body validates eachelement with an early-exit guard, PHPStan failed to understand that, after the loop,
every element satisfies the guard. For example:
PHPStan already supported the keyed form (
foreach ($a as $k => $v)), but thepost-loop array rewrite was gated on a key variable being present. This PR
generalizes that mechanism to the keyless form, so
$idsis now correctly narrowedto
list<string>after the loop.Changes
src/Analyser/NodeScopeResolver.php(processStmtNode(), foreach handling):$stmt->keyVar !== nullgate with($keyVarExpr !== null || $originalValueExpr !== null).narrowing may be projected back onto the array: the original key expression when
a key variable exists, otherwise the original value expression
(
OriginalForeachValueExpr).(narrowed) value variable instead of from
new ArrayDimFetch($expr, $keyVar),which is impossible without a key. Key-type remapping is skipped in that case.
tests/PHPStan/Analyser/nsrt/bug-7076.php: thenoKeyVarcase asserted the old,limited behavior (
array<int|string, mixed>); updated to the now-correctarray<int|string, string>.src/Analyser/ExprHandler/MatchHandler.php: removed a defensiveif (!$cond instanceof Expr\ClassConstFetch) throw …re-check that the sharperinference now flags as always-true. The preceding pre-validation loop already
guarantees every reached condition is an enum-case
ClassConstFetch(it doesbreak 2otherwise), so the check was dead.Root cause
The post-loop array element/key rewrite in
NodeScopeResolverwas written only forthe keyed
foreach, where each iteration is identified byOriginalForeachKeyExprand the element is addressed via
$array[$key]. The keyless form has no keyexpression and no addressable dim fetch, so the whole block was skipped — the array
type never picked up the element narrowing that the body had proven. The fix tracks
the keyless case through
OriginalForeachValueExpr(which is always assigned inMutatingScope::enterForeach(), independent of the key) and rewrites the array'svalue type from the value variable directly.
Analogous cases probed
Because the single code path now handles all keyless guards, the following sibling
constructs were verified to narrow correctly with the same fix (tests added in
bug-5755.phpfor the representative ones):is_string/is_int/… guards withreturn— the reported case.instanceofguards.throwas the early exit (in addition toreturn).foreach ($a as &$v)).array<int|string, …>), in addition tolist<…>.continueguards correctly do not narrow (non-matching elements remain).Test
tests/PHPStan/Analyser/nsrt/bug-5755.phpreproduces every snippet linked inthe issue (the original
Test::Value()/Test::Strings()example and the reducedvalidate()example) plus the analogous cases above, usingassertType(). Theassertions fail without the fix (the array stays un-narrowed) and pass with it.
bug-7076.phpto reflect the corrected behavior.NodeScopeResolverTest, the Analyser suite, the Comparison/DeadCode/Matchrule tests and PHPStan self-analysis all pass.
Fixes phpstan/phpstan#5755