Narrow the falsey branch of isset($a['x']['y']) only when the intermediate offset is known to exist - #6110
Conversation
…mediate offset is known to exist
- `IssetHandler::specifyTypes()` no longer removes types from `$issetExpr->var`
in the falsey branch when that expression itself may be undefined. Removing a
type from `$a['x']` writes `hasOffsetValue('x', ...)` back into `$a` via
`MutatingScope::specifyExpressionType()`, which wrongly asserted that the
intermediate offset exists for the rest of the scope.
- Added `IssetHandler::isAlwaysSet()` which walks the array-dim-fetch chain and
reports whether every offset in it definitely exists. Undefined *variables*
keep the previous behaviour because their certainty is separately degraded to
"maybe" through `IssetExpr`.
- When every possible value of the intermediate expression has the checked
offset set, `isset()` can only be false because the intermediate offset itself
is missing, so the enclosing array is now specified with that offset unset
(`Type::unsetOffset()`). This turns `array{K?: array{Port: int}}` into
`array{}` and `array<int, array{Port: int}>` into
`array<int<min, -1>|int<1, max>, array{Port: int}>` in the falsey branch
instead of `*NEVER*`. The specification is skipped when it would collapse to
`never` (list types cannot express a missing offset 0).
- Same fix covers integer offsets, list offsets, property-fetch bases
(`isset($this->arr['K']['Port'])`), deeper chains (`$r['A']['B']['Port']`) and
multi-var `isset()` (rewritten to an and-chain), all of which leaked the same way.
- Probed and found already correct: `empty()` (delegates to `!isset() || !expr`),
`??`, `unset()`, non-constant offsets, `ArrayAccess` objects, and
`array_key_exists()` (which evaluates its array argument, so the intermediate
offset's existence really is implied there).
SanderMuller
left a comment
There was a problem hiding this comment.
Reviewed and verified this locally, it holds up. Three parts.
Bisect confirmation. The leak was introduced by #4983 ("Fix isset() falsey branch not narrowing array type for dim fetches", 2ccd7b941), first released in 2.1.40. That commit's block was purely additive (before it this path returned new SpecifiedTypes() directly), and 2.1.39 does not contain it, so the regression could not predate 2.1.40. Matches the reported version.
Empirical verification. Applied the PR on top of 2.2.x and checked:
- The four cases from the issue all resolve correctly (
array{...}|nullpreserved after the nestedisset, both controls unchanged). - The isset regression surface stays green: every
nsrt/datatest file that touchesisset(138 with this PR's own file) plus the fullNullCoalesceRuleTest(37 tests).
The isAlwaysSet() gate plus the unsetOffset() replacement (with the list<T> collapse guard) is the sound version of what #4983 was reaching for.
One escaped mutant worth closing. The Mutation Testing check flags one escaped mutant at IssetHandler.php:387, the isAlwaysSet() root-variable check, ->yes() rewritten to !->no(). It escapes because every case in bug-15005.php uses a definitely-set root ($r parameter, $this->arr), so the maybe-defined path through isAlwaysSet() is never exercised. ->yes() is the correct choice (you want "definitely set" before narrowing an intermediate offset); it just is not pinned by a test.
This case kills the mutant. It uses a maybe-defined root whose shape still has a definite offset, so isAlwaysSet() recurses to the root Variable check instead of stopping at an offset check:
function maybeDefinedRoot(bool $cond): void
{
if ($cond) {
/** @var array{K: array{Port: int}} $a */
$a = ['K' => ['Port' => 1]];
}
if (isset($a['K']['Port'])) {
echo $a['K']['Port'];
}
assertType('array{Port: int}|null', $a['K'] ?? null);
}Confirmed locally: passes on the PR as-is, and fails under the exact mutation (Actual: array{Port: int}, the |null leaks back), so it kills the surviving mutant. Dropping it into bug-15005.php should turn the Mutation Testing check green.
Summary
isset()on a nested array offset made PHPStan treat the intermediate offset as definitely existing for the rest of the enclosing scope.Because the falsey branch was narrowed to
never, merging it back with the truthy branch dropped the "offset may not exist" information, which producednullCoalesce.offsetandnullCoalesce.unnecessaryfalse positives on every$a['x']['y'] ?? nullsitting after such anisset().This PR makes the falsey-branch narrowing conditional on the intermediate offset being known to exist, and replaces the unsound narrowing with a precise "the intermediate offset is unset" specification where that is expressible.
Changes
src/Analyser/ExprHandler/IssetHandler.php:isAlwaysSet(), which walks an array-dim-fetch chain and answers whether every offset in it definitely exists (Type::hasOffsetValueType()->yes()at each level, plusScope::hasVariableType()->yes()at the root variable).$issetExpr->varisisAlwaysSet(), or when it is a plainVariable— variables keep the previous behaviour because their certainty is separately degraded to "maybe" viaIssetExpr.never, the only way forisset()to be false is the intermediate offset not existing. That fact is now specified on the enclosing array viaType::unsetOffset()instead of being (wrongly) written onto the intermediate expression.never, which happens forlist<T>—array<int<1, max>, T> & list<T>isneverin PHPStan even though the empty list satisfies both.Tests:
tests/PHPStan/Analyser/nsrt/bug-15005.php(new)tests/PHPStan/Rules/Variables/data/bug-15005.php(new) +testBug15005()intests/PHPStan/Rules/Variables/NullCoalesceRuleTest.phpAnalogous cases also fixed
All of these leaked in exactly the same way and are covered by the new NSRT file:
isset($r[0]['Port'])onarray<int, array{Port: int}>isset($r[0]['Port'])onlist<array{Port: int}>(previously narrowed the whole$rto*NEVER*)array{K?: array{Port: int}}now narrows toarray{}in the falsey branch (previously*NEVER*for the whole array)isset($this->arr['K']['Port'])isset($r['A']['B']['Port']), which leakedhasOffsetValue()two levels upisset($r['K']['Port'], $r['K']['Secure']), which is rewritten to an and-chain of single-varisset()sProbed and found already correct
empty($r['K']['Port'])—EmptyHandlerrewrites to!isset(...) || !expr, so it inherits the fix; the truthy branch was already correct.$r['K']['Port'] ?? null—CoalesceHandlernever performed this narrowing.unset($r['K']['Port'])— already fixed for Unsetting a nested array key makes PHPStan believe the parent array exists phpstan#7292 and unaffected.isset($r[$k]['Port'])) andArrayAccessobjects — the narrowing does not apply.array_key_exists('Port', $r['K'])— structurally similar, but not the same bug: unlikeisset(), it evaluates$r['K'], so the intermediate offset's existence really is implied by the call. Left as-is.Root cause
MutatingScope::specifyExpressionType()propagates a narrowed offset type back into the enclosing array: specifying$a['x']intersects$awithHasOffsetValueType('x', <type>). That write-back is correct when the narrowing implies the offset exists (assignment, the truthy branch ofisset()), butisset()'s falsey branch does not imply it —isset($a['x']['y'])is also false when$a['x']is entirely missing.The 2.1.40 falsey-branch narrowing removed the matching constant arrays from
$a['x']unconditionally. Witharray<string, array{Port: int, ...}>every value hasPortset, so the removal producednever, and the write-back stampedhasOffsetValue('K', *NEVER*)onto$a— simultaneously claiming "K exists" (which leaks out of the branch on merge) and "its value is impossible". The single-variable case in the same method already guarded against this by degrading the variable's certainty to "maybe" throughIssetExpr, but there is no such certainty slot for a nested offset, so the guard has to be an existence precondition instead.The fix therefore splits the two situations: narrow the intermediate expression only when it is known to exist, and otherwise record what is actually known — that the intermediate offset is missing — on the enclosing array.
Test
tests/PHPStan/Analyser/nsrt/bug-15005.phpreproduces the issue's playground sample (nestedIssetLeak,alsoAfterPlainIf,branches) plus the analogous cases listed above and the cases that must keep working (definitelySetIntermediate,empty(),??). Without the fix, 9 of itsassertType()calls fail (*NEVER*in the falsey branch, missing|nullafter theif,hasOffsetValue(...)leaking into$r).tests/PHPStan/Rules/Variables/NullCoalesceRuleTest::testBug15005()asserts that the reportednullCoalesce.unnecessaryandnullCoalesce.offsetfalse positives are gone; without the fix it reports both.make tests,make phpstanandmake csare green.Fixes phpstan/phpstan#15005