From 37b210b59d04f694f08fe844159df01509a51e1f Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 25 May 2026 21:34:52 +0000 Subject: [PATCH 1/7] Create cross-kind conditional expression holders in `BooleanAnd`/`BooleanOr` type specifier with truthy fallback for `isset()` - Add `processBooleanNotSureWithSureConditionalTypes()` which builds conditions from sureNotTypes and holders from sureTypes (cross-kind pairing) - Add `processBooleanSureWithNotSureConditionalTypes()` which builds conditions from sureTypes and holders from sureNotTypes (reverse cross-kind pairing) - Call both new functions (with both argument orderings) in BooleanAnd falsey and BooleanOr truthy conditional holder creation - Add truthy fallback: when left/right falsey types produce empty SpecifiedTypes (e.g. isset() on non-constant array dim fetch), recompute in truthy context and swap sureTypes/sureNotTypes to derive conditions for holders - Guard the truthy fallback with `allExpressionsTrackable()` to prevent over-narrowing when non-trackable expressions (method calls) are involved - Fixes patterns like `if ($a && !is_string($y)) { throw; }` followed by `if ($a) { /* $y is now string */ }` and `if (isset($data['x']) && !is_string($data['x'])) { throw; }` followed by `$data['x'] ?? ''` being correctly inferred as string --- src/Analyser/TypeSpecifier.php | 36 +++++++ tests/PHPStan/Analyser/nsrt/bug-10644.php | 111 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10644.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 01ab4208a3..7875ed1c94 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -735,6 +735,18 @@ public function specifyTypesInCondition( $rightTypesForHolders = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } } + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } $result = new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), @@ -747,6 +759,10 @@ public function specifyTypesInCondition( $this->processBooleanConditionalTypes($scope, $rightTypesForHolders, false, $leftTypesForHolders, false, $scope), $this->processBooleanConditionalTypes($scope, $leftTypesForHolders, true, $rightTypesForHolders, true, $rightScope), $this->processBooleanConditionalTypes($scope, $rightTypesForHolders, true, $leftTypesForHolders, true, $scope), + $this->processBooleanConditionalTypes($scope, $leftTypesForHolders, false, $rightTypesForHolders, true, $rightScope), + $this->processBooleanConditionalTypes($scope, $rightTypesForHolders, false, $leftTypesForHolders, true, $scope), + $this->processBooleanConditionalTypes($scope, $leftTypesForHolders, true, $rightTypesForHolders, false, $rightScope), + $this->processBooleanConditionalTypes($scope, $rightTypesForHolders, true, $leftTypesForHolders, false, $scope), ))->setRootExpr($expr); } @@ -800,6 +816,10 @@ public function specifyTypesInCondition( $this->processBooleanConditionalTypes($scope, $rightTypes, false, $leftTypes, false, $scope), $this->processBooleanConditionalTypes($scope, $leftTypes, true, $rightTypes, true, $rightScope), $this->processBooleanConditionalTypes($scope, $rightTypes, true, $leftTypes, true, $scope), + $this->processBooleanConditionalTypes($scope, $leftTypes, false, $rightTypes, true, $rightScope), + $this->processBooleanConditionalTypes($scope, $rightTypes, false, $leftTypes, true, $scope), + $this->processBooleanConditionalTypes($scope, $leftTypes, true, $rightTypes, false, $rightScope), + $this->processBooleanConditionalTypes($scope, $rightTypes, true, $leftTypes, false, $scope), ))->setRootExpr($expr); } @@ -2143,6 +2163,22 @@ private function isTrackableExpression(Expr $expr): bool || $expr instanceof Expr\StaticPropertyFetch; } + private function allExpressionsTrackable(SpecifiedTypes $types): bool + { + foreach ($types->getSureTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + foreach ($types->getSureNotTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + + return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== []; + } + /** * Flatten a deep BooleanOr chain into leaf expressions and process them * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) diff --git a/tests/PHPStan/Analyser/nsrt/bug-10644.php b/tests/PHPStan/Analyser/nsrt/bug-10644.php new file mode 100644 index 0000000000..ac9aed3ce2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10644.php @@ -0,0 +1,111 @@ + $data + */ +function testIssetCoalesce(array $data): void +{ + if (isset($data['subtitle']) && !is_string($data['subtitle'])) { + throw new \InvalidArgumentException('Subtitle must be a string'); + } + + if (isset($data['subtitle'])) { + assertType("string", $data['subtitle']); + } + assertType("string", $data['subtitle'] ?? ''); +} + +function testSimpleBool(bool $a, mixed $y): void +{ + if ($a && !is_string($y)) { + throw new \Exception(); + } + + if ($a) { + assertType("string", $y); + } + assertType("mixed", $y); +} + +function testSimpleInt(bool $a, mixed $y): void +{ + if ($a && !is_int($y)) { + throw new \Exception(); + } + + if ($a) { + assertType("int", $y); + } +} + +function testSimpleArray(bool $a, mixed $y): void +{ + if ($a && !is_array($y)) { + throw new \Exception(); + } + + if ($a) { + assertType("array", $y); + } +} + +function testNotNull(?int $x, mixed $y): void +{ + if ($x !== null && !is_string($y)) { + throw new \Exception(); + } + + if ($x !== null) { + assertType("string", $y); + } +} + +function testInstanceof(mixed $x, mixed $y): void +{ + if ($x instanceof \stdClass && !is_int($y)) { + throw new \Exception(); + } + + if ($x instanceof \stdClass) { + assertType("int", $y); + } +} + +/** + * @param array $data + */ +function testIssetMultipleKeys(array $data): void +{ + if (isset($data['a']) && !is_string($data['a'])) { + throw new \Exception(); + } + if (isset($data['b']) && !is_int($data['b'])) { + throw new \Exception(); + } + + if (isset($data['a'])) { + assertType("string", $data['a']); + } + if (isset($data['b'])) { + assertType("int", $data['b']); + } + assertType("string", $data['a'] ?? ''); + assertType("int", $data['b'] ?? 0); +} + +/** + * @param array $data + */ +function testArrayKeyExists(array $data): void +{ + if (array_key_exists('subtitle', $data) && !is_string($data['subtitle'])) { + throw new \Exception(); + } + if (array_key_exists('subtitle', $data)) { + assertType("string", $data['subtitle']); + } +} From 2989bdf0760634f83bfbe420edddabafe8477021 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 12:36:24 +0000 Subject: [PATCH 2/7] Use PHPDoc mixed type instead of native mixed for PHP 7.4 compatibility Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-10644.php | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-10644.php b/tests/PHPStan/Analyser/nsrt/bug-10644.php index ac9aed3ce2..91a9e07075 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10644.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10644.php @@ -19,7 +19,10 @@ function testIssetCoalesce(array $data): void assertType("string", $data['subtitle'] ?? ''); } -function testSimpleBool(bool $a, mixed $y): void +/** + * @param mixed $y + */ +function testSimpleBool(bool $a, $y): void { if ($a && !is_string($y)) { throw new \Exception(); @@ -31,7 +34,10 @@ function testSimpleBool(bool $a, mixed $y): void assertType("mixed", $y); } -function testSimpleInt(bool $a, mixed $y): void +/** + * @param mixed $y + */ +function testSimpleInt(bool $a, $y): void { if ($a && !is_int($y)) { throw new \Exception(); @@ -42,7 +48,10 @@ function testSimpleInt(bool $a, mixed $y): void } } -function testSimpleArray(bool $a, mixed $y): void +/** + * @param mixed $y + */ +function testSimpleArray(bool $a, $y): void { if ($a && !is_array($y)) { throw new \Exception(); @@ -53,7 +62,10 @@ function testSimpleArray(bool $a, mixed $y): void } } -function testNotNull(?int $x, mixed $y): void +/** + * @param mixed $y + */ +function testNotNull(?int $x, $y): void { if ($x !== null && !is_string($y)) { throw new \Exception(); @@ -64,7 +76,11 @@ function testNotNull(?int $x, mixed $y): void } } -function testInstanceof(mixed $x, mixed $y): void +/** + * @param mixed $x + * @param mixed $y + */ +function testInstanceof($x, $y): void { if ($x instanceof \stdClass && !is_int($y)) { throw new \Exception(); From 5a09a6640b58d21a0199c1ab410224046b9225d9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 12:36:28 +0000 Subject: [PATCH 3/7] Add regression tests for issues #11918, #3385, #6202, #14455 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11918.php | 18 ++++++++++ tests/PHPStan/Analyser/nsrt/bug-14455.php | 27 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-3385.php | 40 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-6202.php | 21 ++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11918.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14455.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-3385.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6202.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11918.php b/tests/PHPStan/Analyser/nsrt/bug-11918.php new file mode 100644 index 0000000000..9e5cab77a4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11918.php @@ -0,0 +1,18 @@ +|string|false> $options + */ +function testArrayKeyExistsCoalesce(array $options): void +{ + if (array_key_exists('a', $options) && !is_string($options['a'])) { + exit(1); + } + + $a = $options['a'] ?? 'fallback'; + assertType('string', $a); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14455.php b/tests/PHPStan/Analyser/nsrt/bug-14455.php new file mode 100644 index 0000000000..d6b6c8b92f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14455.php @@ -0,0 +1,27 @@ + $aggregation + * @param non-falsy-string $type + */ +function testTriviallyTrueConditionSkipped(array $aggregation, string $type): void +{ + if (empty($aggregation['field']) && $type === 'filter') { + return; + } + + assertType("array", $aggregation); + assertType('non-falsy-string', $type); + + if ($type === 'filter') { + assertType("non-empty-array&hasOffset('field')", $aggregation); + } else { + assertType("array", $aggregation); + } + + assertType('non-falsy-string', $type); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-3385.php b/tests/PHPStan/Analyser/nsrt/bug-3385.php new file mode 100644 index 0000000000..dc371da847 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-3385.php @@ -0,0 +1,40 @@ +sayHello() === $otherGreeter->sayHello(); + } + +} + +function isGreeterDifferent(?Greeter $greeterA, ?Greeter $greeterB): bool +{ + if ($greeterA === null && $greeterB !== null) { + return true; + } + + if ($greeterA !== null && $greeterB === null) { + return true; + } + + if ($greeterA === null && $greeterB === null) { + return false; + } + + assertType('Bug3385\Greeter', $greeterA); + assertType('Bug3385\Greeter', $greeterB); + + return $greeterA->isEqualTo($greeterB); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-6202.php b/tests/PHPStan/Analyser/nsrt/bug-6202.php new file mode 100644 index 0000000000..580196103e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6202.php @@ -0,0 +1,21 @@ + $array + */ + public function sayHello(array $array): void + { + if (isset($array['mightExist']) && !is_string($array['mightExist'])) { + throw new \Exception('Has to be string if set'); + } + assertType('string', $array['mightExist'] ?? ''); + } + +} From 70f347f4dfccce50a24aeaab735e608c9a1b9a94 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 10:06:22 +0000 Subject: [PATCH 4/7] Restrict isset() truthy fallback to pure false context in BooleanAnd holders The truthy-narrowing fallback for empty falsey SpecifiedTypes (added for isset() on non-constant array dim fetches) previously ran unconditionally inside the `$context->false()` block. In a mixed truthy-and-false context (produced by negation) it would run in addition to the existing falsey re-derivation, supplementing holders the truthy branch had already filled. Make it an explicit `else` branch so the two re-derivation strategies are mutually exclusive: the mixed truthy-and-false context re-derives from the falsey narrowing, while the pure false context falls back to the truthy narrowing. Add comments documenting both branches. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/TypeSpecifier.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7875ed1c94..ef5deef371 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -728,23 +728,29 @@ public function specifyTypesInCondition( $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; if ($context->truthy()) { + // Mixed truthy-and-false context (produced by negation): re-derive + // empty holders from the falsey narrowing of each arm. if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { $leftTypesForHolders = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { $rightTypesForHolders = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } - } - if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { - $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } else { + // Pure false context: when the falsey narrowing of an arm is empty + // (e.g. isset() on a non-constant array dim fetch), derive conditions + // from its truthy narrowing instead, swapping sure/sureNot types. + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } } - } - if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { - $truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } } } $result = new SpecifiedTypes( From 6d1c6d6c235b3aee03b6d0d98709c764782e224c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 13:09:23 +0000 Subject: [PATCH 5/7] Add regression test for negated BooleanAnd conditional holders in mixed context Negating a `&&` condition specifies the inner BooleanAnd in a mixed truthy-and-false context, which is the path refined by the previous commit (the isset() truthy fallback must not fire there; holders are re-derived from the falsey narrowing instead). These cases produced `mixed` before the PR and now narrow correctly through that path. Co-Authored-By: Claude Opus 4.8 --- ...egated-boolean-and-conditional-holders.php | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php diff --git a/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php b/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php new file mode 100644 index 0000000000..6efaa8a559 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php @@ -0,0 +1,72 @@ + $data + */ +function negatedIsset(array $data): void +{ + if (!(isset($data['subtitle']) && !is_string($data['subtitle']))) { + // key is either unset or a string + assertType('string', $data['subtitle'] ?? 'fallback'); + if (isset($data['subtitle'])) { + assertType('string', $data['subtitle']); + } + } +} + +/** + * @param mixed $y + */ +function negatedSimpleBool(bool $a, $y): void +{ + if (!($a && !is_string($y))) { + if ($a) { + assertType('string', $y); + } + } +} + +/** + * @param mixed $y + */ +function negatedSimpleInt(bool $a, $y): void +{ + if (!($a && !is_int($y))) { + if ($a) { + assertType('int', $y); + } + } +} + +/** + * @param mixed $y + */ +function negatedNotNull(?int $x, $y): void +{ + if (!($x !== null && !is_string($y))) { + if ($x !== null) { + assertType('string', $y); + } + } +} + +/** + * @param array $data + */ +function negatedArrayKeyExists(array $data): void +{ + if (!(array_key_exists('subtitle', $data) && !is_string($data['subtitle']))) { + if (array_key_exists('subtitle', $data)) { + assertType('string', $data['subtitle']); + } + } +} From a1eb395ce24857ef068ccbda8998d8589626df35 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 14:05:11 +0000 Subject: [PATCH 6/7] Run the isset() truthy fallback in the mixed truthy-and-false context too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The truthy-narrowing fallback (added for isset()/array_key_exists() on array dim fetches, whose falsey narrowing is empty) was gated behind an `else` so it only ran in a pure false context. But the mixed truthy-and-false context — reached by negating a `=== true` comparison, e.g. `(isset($data[$key]) && !is_string($data[$key])) === true` — also leaves those arms empty after the falsey re-derivation, so gating the fallback there dropped the narrowing and the `?? 'fallback'` expression widened back to `mixed~null` instead of `string`. Run the fallback unconditionally after the falsey re-derivation. It only fills arms the re-derivation left empty, so it never overrides a non-empty falsey result — which addresses the original concern about the two blocks conflicting. Add boolean-and-conditional-holders-mixed-context.php covering the `=== true` mixed-context path (which fails when the fallback is gated to pure-false only), and correct the misleading header of negated-boolean-and-conditional-holders.php (`!(...)` specifies a pure falsey context, not a mixed one). Co-Authored-By: Claude Opus 4.8 --- src/Analyser/TypeSpecifier.php | 34 ++++++----- ...-and-conditional-holders-mixed-context.php | 61 +++++++++++++++++++ ...egated-boolean-and-conditional-holders.php | 8 +-- 3 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/boolean-and-conditional-holders-mixed-context.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index ef5deef371..b8611ab225 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -727,30 +727,32 @@ public function specifyTypesInCondition( if ($context->false()) { $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; + // In a mixed truthy-and-false context (produced by negating a `=== true` + // comparison) re-derive empty holders from the falsey narrowing of each arm. if ($context->truthy()) { - // Mixed truthy-and-false context (produced by negation): re-derive - // empty holders from the falsey narrowing of each arm. if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { $leftTypesForHolders = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { $rightTypesForHolders = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } - } else { - // Pure false context: when the falsey narrowing of an arm is empty - // (e.g. isset() on a non-constant array dim fetch), derive conditions - // from its truthy narrowing instead, swapping sure/sureNot types. - if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { - $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); - } + } + // Fallback for any arm still empty: when the falsey narrowing produces + // nothing (e.g. isset() on an array dim fetch) derive conditions from the + // truthy narrowing instead, swapping sure/sureNot types. This only fills + // arms the block above left empty — it never overrides a non-empty falsey + // re-derivation — and is needed in both the pure false context and the + // mixed truthy-and-false context. + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); } - if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { - $truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); - } + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); } } $result = new SpecifiedTypes( diff --git a/tests/PHPStan/Analyser/nsrt/boolean-and-conditional-holders-mixed-context.php b/tests/PHPStan/Analyser/nsrt/boolean-and-conditional-holders-mixed-context.php new file mode 100644 index 0000000000..c64c60002e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/boolean-and-conditional-holders-mixed-context.php @@ -0,0 +1,61 @@ + $data + */ +function issetVarKey(array $data, string $key): void +{ + if ((isset($data[$key]) && !is_string($data[$key])) === true) { + return; + } + + assertType('string', $data[$key] ?? 'fallback'); +} + +/** + * @param array $data + */ +function issetConstKey(array $data): void +{ + if ((isset($data['k']) && !is_string($data['k'])) === true) { + return; + } + + assertType('string', $data['k'] ?? 'fallback'); +} + +/** + * @param array $data + */ +function arrayKeyExistsMixed(array $data): void +{ + if ((array_key_exists('k', $data) && !is_string($data['k'])) === true) { + return; + } + + assertType('string', $data['k'] ?? 'fallback'); +} + +/** + * @param mixed $y + */ +function simpleBoolMixed(bool $a, $y): void +{ + if (($a && !is_string($y)) === true) { + return; + } + + if ($a) { + assertType('string', $y); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php b/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php index 6efaa8a559..5b15a705b2 100644 --- a/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php +++ b/tests/PHPStan/Analyser/nsrt/negated-boolean-and-conditional-holders.php @@ -4,10 +4,10 @@ use function PHPStan\Testing\assertType; -// The negation of a `&&` condition specifies the inner BooleanAnd in a mixed -// truthy-and-false context. The cross-kind conditional holders must still be -// created from the falsey narrowing in that context (and the isset() truthy -// fallback must NOT fire there). +// Negating a `&&` condition with `!(...)` specifies the inner BooleanAnd in a +// pure falsey context. The cross-kind conditional holders (and the isset() +// truthy fallback) must be created there too. For the mixed truthy-and-false +// context reached via `=== true`, see boolean-and-conditional-holders-mixed-context.php. /** * @param array $data From 1b02efbdf7d57c40aea1acc7dd27970fb0cdf86c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 14:20:32 +0000 Subject: [PATCH 7/7] Use less verbose comments in BooleanAnd conditional holder re-derivation Co-Authored-By: Claude Opus 4.8 --- src/Analyser/TypeSpecifier.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index b8611ab225..fe4ec9b65d 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -727,8 +727,7 @@ public function specifyTypesInCondition( if ($context->false()) { $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; - // In a mixed truthy-and-false context (produced by negating a `=== true` - // comparison) re-derive empty holders from the falsey narrowing of each arm. + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. if ($context->truthy()) { if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { $leftTypesForHolders = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr); @@ -737,12 +736,8 @@ public function specifyTypesInCondition( $rightTypesForHolders = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); } } - // Fallback for any arm still empty: when the falsey narrowing produces - // nothing (e.g. isset() on an array dim fetch) derive conditions from the - // truthy narrowing instead, swapping sure/sureNot types. This only fills - // arms the block above left empty — it never overrides a non-empty falsey - // re-derivation — and is needed in both the pure false context and the - // mixed truthy-and-false context. + // For arms still empty (e.g. isset() on an array dim fetch), derive conditions + // from the truthy narrowing instead, swapping sure/sureNot types. if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { $truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); if ($this->allExpressionsTrackable($truthyLeftTypes)) {