From dd478aa32df45ebe80445a2c56ea364666c7dded Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Mon, 3 Aug 2026 10:47:10 +0200 Subject: [PATCH] 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) --- src/Analyser/ExprHandler/IssetHandler.php | 4 ++ tests/PHPStan/Analyser/nsrt/bug-15005.php | 73 +++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15005.php diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 7f0d6eb5e3..82c1ae5af7 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -196,6 +196,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ( $issetExpr instanceof ArrayDimFetch && $issetExpr->dim !== null + // When the var is itself an offset access (a nested isset like + // $r['K']['Port']), narrowing it in the falsey branch leaks the + // intermediate offset's existence into the enclosing scope. + && !($issetExpr->var instanceof ArrayDimFetch) ) { $varType = $scope->getType($issetExpr->var); if (!$varType instanceof MixedType) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15005.php b/tests/PHPStan/Analyser/nsrt/bug-15005.php new file mode 100644 index 0000000000..9589d9503d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15005.php @@ -0,0 +1,73 @@ + $r */ +function nestedIssetLeak(array $r): void +{ + assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null); + + $port = isset($r['K']['Port']) ? $r['K']['Port'] : null; + + assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null); + + $secure = $r['K']['Secure'] ?? null; + + echo $port, $secure; +} + +/** @param array $r */ +function alsoAfterPlainIf(array $r): void +{ + if (isset($r['K']['Port'])) { + echo $r['K']['Port']; + } + + assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null); +} + +/** @param array $r */ +function notWithCoalesce(array $r): void +{ + $port = $r['K']['Port'] ?? null; + assertType('array{Port: int, Secure: string|null}|null', $r['K'] ?? null); + echo $port; +} + +/** @param array $r */ +function notWithSingleLevel(array $r): void +{ + $port = isset($r['K']) ? $r['K'] : null; + assertType('string|null', $r['K'] ?? null); + echo $port; +} + +/** @param array> $r */ +function threeLevels(array $r): void +{ + if (isset($r['A']['B']['Port'])) { + echo $r['A']['B']['Port']; + } + + assertType('array{Port: int}|null', $r['A']['B'] ?? null); + assertType('array|null', $r['A'] ?? null); +} + +class Holder +{ + + /** @var array */ + public array $arr = []; + + public function doFoo(): void + { + if (isset($this->arr['K']['Port'])) { + echo $this->arr['K']['Port']; + } + + assertType('array{Port: int, Secure: string|null}|null', $this->arr['K'] ?? null); + } + +}