From 474bcf562da4f89a48b6833f5787de657e47bfbb Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:22:49 +0000 Subject: [PATCH 1/4] Cover subtype-absorbed try/catch results surviving as conditional targets under `if`, `&&`, and ternary guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The reported bug (a try-branch-assigned `array` collapsing to `mixed` inside `if (!$hasError)`) was already fixed by MutatingScope::createConditionalExpressions keeping subtype-absorbed variables as conditional targets instead of dropping them (commit c53a7b8a5 / PR #5876, already on this branch). - Extends tests/PHPStan/Analyser/nsrt/bug-11281.php with analogous control-flow forms that exercise the same branch-merge path: - positive guard `if ($ok)` - guard combined with `&&` (`if ($other && $ok)`) - guard read through a ternary (`$ok ? $values : []`) - Probed further siblings (switch/while/do-while, nested ifs, elseif chains, property targets, non-bool guards, super/subtype directions, and the array_key_exists/isset/`??` ternary family) — all already infer the precise type, so no source change was needed. --- tests/PHPStan/Analyser/nsrt/bug-11281.php | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11281.php b/tests/PHPStan/Analyser/nsrt/bug-11281.php index fa9d543098..3d9fc067dd 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11281.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11281.php @@ -25,6 +25,48 @@ function hello2(string $values): void } } +/** + * The merged subtype-absorbed variable must survive as a conditional target + * regardless of which control-flow form reads the guard afterwards. + */ +function positiveGuard(string $values): void +{ + $ok = false; + try { + $values = array_map(static fn ($item) => Hello::fromObject($item), json_decode($values)); + $ok = true; + } catch (\Throwable) { + } + if ($ok) { + assertType('array', $values); + } +} + +function nestedGuard(string $values, bool $other): void +{ + $ok = false; + try { + $values = array_map(static fn ($item) => Hello::fromObject($item), json_decode($values)); + $ok = true; + } catch (\Throwable) { + } + if ($other && $ok) { + assertType('array', $values); + } +} + +function ternaryGuard(string $values): void +{ + $ok = false; + try { + $values = array_map(static fn ($item) => Hello::fromObject($item), json_decode($values)); + $ok = true; + } catch (\Throwable) { + } + $result = $ok ? $values : []; + assertType('array', $result); +} + final class Hello { From 2748eab11d950be9cc69ff63feb1eed093e75126 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 08:40:58 +0000 Subject: [PATCH 2/4] Report ternary argument branches whose type is hidden by mixed normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a ternary is passed as a function/method argument and its resulting type normalizes to `mixed` (e.g. `mixed|string` collapses to `mixed`), a branch carrying a definitely-wrong value — such as a `string` passed to an `int` parameter — was silently accepted. Inspect the ternary's branch types separately in their narrowed scopes when the whole argument collapsed to `mixed`, so the offending branch is still reported. The branch inspection is gated on the mixed collapse, since a non-mixed resulting type keeps enough information for the regular argument check and branch-by-branch checking would otherwise flag legitimate normalizations (e.g. an `ArrayIterator` branch absorbed into `Traversable`). Closes https://github.com/phpstan/phpstan/issues/11281 Co-Authored-By: Claude Opus 4.8 --- src/Rules/FunctionCallParametersCheck.php | 56 +++++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 14 +++++ .../Rules/Functions/data/bug-11281.php | 34 +++++++++++ 3 files changed, 104 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11281.php diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 0ba5e1a7a5..ac0b1bda59 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -26,6 +26,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -412,6 +413,32 @@ public function check( ->line($argumentLine) ->acceptsReasonsTip($accepts->reasons) ->build(); + } elseif ($argumentValue instanceof Expr\Ternary && $argumentValueType instanceof MixedType) { + // Type normalization can collapse a ternary's resulting type to + // mixed (e.g. mixed|string becomes mixed), hiding a branch whose + // type is not accepted. When the whole argument collapsed to mixed, + // inspect the branch types separately so such a passed value is + // still reported. A non-mixed resulting type keeps enough + // information for the regular check above, so branch inspection + // would only introduce false positives there. + foreach ($this->getTernaryBranchTypes($argumentValue, $scope) as $branchType) { + $branchAccepts = $this->ruleLevelHelper->accepts($parameterType, $branchType, $isStrictTypes); + if ($branchAccepts->result) { + continue; + } + + $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $branchType); + $errors[] = RuleErrorBuilder::message(sprintf( + $wrongArgumentTypeMessage, + $this->describeParameter($parameter, $argumentName ?? $i + 1), + $parameterType->describe($verbosityLevel), + $branchType->describe($verbosityLevel), + )) + ->identifier('argument.type') + ->line($argumentLine) + ->acceptsReasonsTip($branchAccepts->reasons) + ->build(); + } } } @@ -801,6 +828,35 @@ private function describeParameter(ParameterReflection $parameter, int|string|nu return implode(' ', $parts); } + /** + * Collects the leaf types of a ternary's branches, each resolved in the scope + * narrowed by the controlling condition. Nested ternaries are flattened so every + * value the expression can produce is represented by its own (un-normalized) type. + * + * @return list + */ + private function getTernaryBranchTypes(Expr\Ternary $ternary, Scope $scope): array + { + $truthyScope = $scope->filterByTruthyValue($ternary->cond); + $falseyScope = $scope->filterByFalseyValue($ternary->cond); + + if ($ternary->if === null) { + $ifTypes = [TypeCombinator::removeFalsey($truthyScope->getType($ternary->cond))]; + } elseif ($ternary->if instanceof Expr\Ternary) { + $ifTypes = $this->getTernaryBranchTypes($ternary->if, $truthyScope); + } else { + $ifTypes = [$truthyScope->getType($ternary->if)]; + } + + if ($ternary->else instanceof Expr\Ternary) { + $elseTypes = $this->getTernaryBranchTypes($ternary->else, $falseyScope); + } else { + $elseTypes = [$falseyScope->getType($ternary->else)]; + } + + return array_merge($ifTypes, $elseTypes); + } + /** * @return list|null Null when the expression is not a constant or bitmask of constants */ diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 1d4dd576ae..a2a25e1fd4 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2978,4 +2978,18 @@ public function testBug11494(): void ]); } + public function testBug11281(): void + { + $this->analyse([__DIR__ . '/data/bug-11281.php'], [ + [ + 'Parameter #1 $i of function Bug11281Functions\sayHello expects int, string given.', + 16, + ], + [ + 'Parameter #1 $i of function Bug11281Functions\sayHello expects int, string given.', + 33, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11281.php b/tests/PHPStan/Rules/Functions/data/bug-11281.php new file mode 100644 index 0000000000..69ee17fb57 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11281.php @@ -0,0 +1,34 @@ + $values + */ +function test(array $values): void +{ + // The ternary's resulting type normalizes to mixed (mixed|string), + // but the else branch is definitely a string passed to an int parameter. + sayHello(array_key_exists('key', $values) ? $values['key'] : ' a string'); +} + +/** + * @param array $values + */ +function noError(array $values): void +{ + // Numeric-ish coercible branches must not be flagged. + sayHello(array_key_exists('key', $values) ? $values['key'] : 5); +} + +/** + * @param array $values + */ +function nested(array $values, bool $other, bool $another): void +{ + sayHello($other ? $values['key'] : ($another ? 1 : ' nested string')); +} From 9a764f9ab31169d85b6d7a847324296c8824cce8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 13:40:36 +0000 Subject: [PATCH 3/4] Narrow ternary else branch via negated condition in branch-type inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTernaryBranchTypes resolved the else branch with filterByFalseyValue($cond), which narrows asymmetrically for conditions whose stub only declares @phpstan-assert-if-true (e.g. is_resource()). The falsey scope then diverged from the type the ternary actually produces — is_resource($value) ? stream_get_contents($value) : $value yielded a spurious `resource` for $value instead of the `mixed` the ternary really produces, triggering a false-positive argument.type error when passed to a string parameter. Resolve the else branch with filterByTruthyValue(!cond) instead, mirroring how TernaryHandler::specifyTypes models the else branch (BooleanNot of the condition). This keeps the #11281 fix intact while removing the false positive. Co-Authored-By: Claude Opus 4.8 --- src/Rules/FunctionCallParametersCheck.php | 9 ++++++++- .../CallToFunctionParametersRuleTest.php | 4 ++-- .../Rules/Functions/data/bug-11281.php | 20 ++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index ac0b1bda59..3ea9223f7c 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -833,12 +833,19 @@ private function describeParameter(ParameterReflection $parameter, int|string|nu * narrowed by the controlling condition. Nested ternaries are flattened so every * value the expression can produce is represented by its own (un-normalized) type. * + * The else branch is narrowed by the negated condition (`filterByTruthyValue` of + * `!cond`) rather than `filterByFalseyValue($cond)`, mirroring how + * TernaryHandler::specifyTypes models the else branch. Some conditions (e.g. + * `is_resource()`, whose stub only declares `@phpstan-assert-if-true`) narrow + * asymmetrically, so the falsey scope would otherwise diverge from the type the + * ternary actually produces and report spurious branch types. + * * @return list */ private function getTernaryBranchTypes(Expr\Ternary $ternary, Scope $scope): array { $truthyScope = $scope->filterByTruthyValue($ternary->cond); - $falseyScope = $scope->filterByFalseyValue($ternary->cond); + $falseyScope = $scope->filterByTruthyValue(new Expr\BooleanNot($ternary->cond)); if ($ternary->if === null) { $ifTypes = [TypeCombinator::removeFalsey($truthyScope->getType($ternary->cond))]; diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index a2a25e1fd4..e324e3c758 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2983,11 +2983,11 @@ public function testBug11281(): void $this->analyse([__DIR__ . '/data/bug-11281.php'], [ [ 'Parameter #1 $i of function Bug11281Functions\sayHello expects int, string given.', - 16, + 18, ], [ 'Parameter #1 $i of function Bug11281Functions\sayHello expects int, string given.', - 33, + 35, ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11281.php b/tests/PHPStan/Rules/Functions/data/bug-11281.php index 69ee17fb57..c6a8d0e650 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11281.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11281.php @@ -1,4 +1,6 @@ -= 8.0 + +declare(strict_types = 1); namespace Bug11281Functions; @@ -32,3 +34,19 @@ function nested(array $values, bool $other, bool $another): void { sayHello($other ? $values['key'] : ($another ? 1 : ' nested string')); } + +function expectsString(string $s): void +{ +} + +function falsePositive(mixed $value): void +{ + // is_resource() only narrows asymmetrically (@phpstan-assert-if-true), so the + // else branch must keep the type the ternary actually produces (mixed, accepted), + // not a spurious narrowing. No error should be reported here. + expectsString( + is_resource($value) + ? stream_get_contents($value) + : $value, + ); +} From d1df3ed40e45f213aaace295b4b33d427794ce50 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 26 Jun 2026 13:55:09 +0000 Subject: [PATCH 4/4] Clarify ternary branch-inspection test: benevolent vs strict string|false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `falsePositive` case used `stream_get_contents()`, which returns a *benevolent* string|false. PHPStan intentionally accepts benevolent unions for a string parameter, so the equivalent direct call is error-free too — and reporting the branch would re-introduce the very pg_escape_bytea false positive this branch inspection guards against (its second `string` parameter is fed the same `stream_get_contents()` benevolent union). Document that no-error case precisely and add a companion `strictFalseBranchReported` showing that a *strict* (non-benevolent) string|false branch under the same asymmetric `is_resource()` narrowing IS reported. This makes branch inspection's consistency with PHPStan's standard accept semantics explicit. Co-Authored-By: Claude Opus 4.8 --- .../CallToFunctionParametersRuleTest.php | 4 ++++ .../Rules/Functions/data/bug-11281.php | 24 +++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index e324e3c758..7df47077e5 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2989,6 +2989,10 @@ public function testBug11281(): void 'Parameter #1 $i of function Bug11281Functions\sayHello expects int, string given.', 35, ], + [ + 'Parameter #1 $s of function Bug11281Functions\expectsString expects string, string|false given.', + 64, + ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11281.php b/tests/PHPStan/Rules/Functions/data/bug-11281.php index c6a8d0e650..78cd18e10e 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11281.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11281.php @@ -39,14 +39,30 @@ function expectsString(string $s): void { } -function falsePositive(mixed $value): void +function benevolentBranchNotReported(mixed $value): void { - // is_resource() only narrows asymmetrically (@phpstan-assert-if-true), so the - // else branch must keep the type the ternary actually produces (mixed, accepted), - // not a spurious narrowing. No error should be reported here. + // stream_get_contents() returns a *benevolent* string|false. PHPStan intentionally + // accepts benevolent unions for a string parameter, so the equivalent direct call + // expectsString(stream_get_contents($r)) is error-free too — reporting it here would + // re-introduce the pg_escape_bytea false positive this branch inspection guards + // against. is_resource() also narrows asymmetrically (@phpstan-assert-if-true only), + // so the else branch must keep the type the ternary actually produces (mixed, + // accepted) instead of a spurious narrowing. No error should be reported here. expectsString( is_resource($value) ? stream_get_contents($value) : $value, ); } + +function strictFalseBranchReported(mixed $value, string|false $sf): void +{ + // A *strict* (non-benevolent) string|false branch is not accepted by the string + // parameter, so branch inspection reports it even though is_resource() narrows + // asymmetrically and the ternary's resulting type normalizes to mixed. + expectsString( + is_resource($value) + ? $sf + : $value, + ); +}