Skip to content

Commit dd478aa

Browse files
SanderMullerclaude
andcommitted
Do not narrow the falsey branch of a nested isset() at its intermediate offset
The falsey-branch dim-fetch narrowing in IssetHandler::specifyTypes() was written for a single-level isset(), where the target's var is a plain variable or property. For a nested isset() like isset($r['K']['Port']), the target's var is itself an ArrayDimFetch ($r['K']) that may not exist, and narrowing that intermediate offset in the falsey branch leaks its existence into the enclosing scope, so $r['K'] ?? null lost its |null after the check. Skip the block when the var is itself an offset access. Single-level isset() keeps the narrowing #4983 added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0e53dd0 commit dd478aa

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

src/Analyser/ExprHandler/IssetHandler.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e
196196
if (
197197
$issetExpr instanceof ArrayDimFetch
198198
&& $issetExpr->dim !== null
199+
// When the var is itself an offset access (a nested isset like
200+
// $r['K']['Port']), narrowing it in the falsey branch leaks the
201+
// intermediate offset's existence into the enclosing scope.
202+
&& !($issetExpr->var instanceof ArrayDimFetch)
199203
) {
200204
$varType = $scope->getType($issetExpr->var);
201205
if (!$varType instanceof MixedType) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15005;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/** @param array<string, array{Port: int, Secure: string|null}> $r */
8+
function nestedIssetLeak(array $r): void
9+
{
10+
assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null);
11+
12+
$port = isset($r['K']['Port']) ? $r['K']['Port'] : null;
13+
14+
assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null);
15+
16+
$secure = $r['K']['Secure'] ?? null;
17+
18+
echo $port, $secure;
19+
}
20+
21+
/** @param array<string, array{Port: int, Secure: string|null}> $r */
22+
function alsoAfterPlainIf(array $r): void
23+
{
24+
if (isset($r['K']['Port'])) {
25+
echo $r['K']['Port'];
26+
}
27+
28+
assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null);
29+
}
30+
31+
/** @param array<string, array{Port: int, Secure: string|null}> $r */
32+
function notWithCoalesce(array $r): void
33+
{
34+
$port = $r['K']['Port'] ?? null;
35+
assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null);
36+
echo $port;
37+
}
38+
39+
/** @param array<string, string|null> $r */
40+
function notWithSingleLevel(array $r): void
41+
{
42+
$port = isset($r['K']) ? $r['K'] : null;
43+
assertType('string|null', $r['K'] ?? null);
44+
echo $port;
45+
}
46+
47+
/** @param array<string, array<string, array{Port: int}>> $r */
48+
function threeLevels(array $r): void
49+
{
50+
if (isset($r['A']['B']['Port'])) {
51+
echo $r['A']['B']['Port'];
52+
}
53+
54+
assertType('array{Port: int}|null', $r['A']['B'] ?? null);
55+
assertType('array<string, array{Port: int}>|null', $r['A'] ?? null);
56+
}
57+
58+
class Holder
59+
{
60+
61+
/** @var array<string, array{Port: int, Secure: string|null}> */
62+
public array $arr = [];
63+
64+
public function doFoo(): void
65+
{
66+
if (isset($this->arr['K']['Port'])) {
67+
echo $this->arr['K']['Port'];
68+
}
69+
70+
assertType('array{Port: int, Secure: string|null}|null', $this->arr['K'] ?? null);
71+
}
72+
73+
}

0 commit comments

Comments
 (0)