Skip to content

Cover subtype-absorbed try/catch results surviving as conditional targets under if, &&, and ternary guards#5939

Open
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-4zzdt1w
Open

Cover subtype-absorbed try/catch results surviving as conditional targets under if, &&, and ternary guards#5939
phpstan-bot wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-4zzdt1w

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

The issue reported that a variable assigned inside a try block (e.g. array_map(...) producing array<Hello>) was being inferred as mixed when read later under a guard such as if (!$hasError). The root cause was that, when merging the try and catch branch scopes, a variable whose successful-branch type was a subtype of the other branch's type got absorbed by the union (merged type equals the other branch) and was then dropped entirely as a conditional-expression target — so the later guard could no longer recover the precise type.

That bug was already fixed on this branch by MutatingScope::createConditionalExpressions(), which now keeps such subtype-absorbed variables as conditional targets and only excludes them from guard selection (commit c53a7b8a5, PR #5876). This change adds regression coverage for that fix across the parallel control-flow forms that funnel through the same branch-merge path.

Changes

  • tests/PHPStan/Analyser/nsrt/bug-11281.php — adds three functions asserting the merged array<Bug11281\Hello> type is preserved when the success guard is read via:
    • a positive if ($ok) check (positiveGuard)
    • a guard combined with && (nestedGuard)
    • a ternary on the guard (ternaryGuard)

No source change was required — src/Analyser/MutatingScope.php already contains the fix and applies it symmetrically (both (our, their) and (their, our) orderings in mergeWith()).

Root cause

createConditionalExpressions() previously did unset($newVariableTypes[$exprString]) for any variable whose our-branch type was absorbed by the merged union, discarding it as a conditional target. A subtype-absorbed variable is a poor guard (asserting its narrow type wouldn't reliably select that branch) but is still a valid conditional target. The fix records such variables in a guardsToExclude set and skips them only during guard selection, leaving them available as targets so a later guard read narrows them back to the precise branch type.

I audited the sibling merge helpers (intersectConditionalExpressions, preserveVacuousConditionalExpressions, mergeConditionalExpressions) — none contain the unguarded "drop the target" pattern, so there is no analogous location to patch.

Test

  • Reproduced the original report (try/catch assigning array<Hello> read under if (!$hasError)) and confirmed it now infers array<Hello> rather than mixed.
  • Added the three control-flow variants above, all passing.
  • Probed (and confirmed already correct, so not kept) further siblings: switch/while/do-while, nested if, elseif chains, property targets, non-boolean guard variables, super/subtype direction reversal, and the array_key_exists/isset/?? ternary family on typed arrays.

Fixes phpstan/phpstan#11281

…gets under `if`, `&&`, and ternary guards

- The reported bug (a try-branch-assigned `array<Hello>` collapsing to `mixed`
  inside `if (!$hasError)`) was already fixed by MutatingScope::createConditionalExpressions
  keeping subtype-absorbed variables as conditional targets instead of dropping
  them (commit c53a7b8 / PR phpstan#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.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR should make sure we get an error in https://phpstan.org/r/77b815da-653a-477f-92cb-50d04c52eaf4 on line 6, regarding a invalid type 'a string' beeing passed into a function expecting an int.

because of type normalization, I think this cannot be solved at the type level of the ternary resulting type.
we may detect it with a special case for Ternary by inspecting if and else-case types separately though

…ization

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<mixed>`
branch absorbed into `Traversable<T>`).

Closes phpstan/phpstan#11281

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed staabm's review. Here's a summary of what I did.

The review

The reviewer pointed out that this PR only added regression coverage for an already-fixed sibling case, but the original issue #11281 reproducer (the array_key_exists ternary at line 6) still wasn't fixed — PHPStan should report string given where an int is expected. The reviewer correctly noted this can't be solved at the ternary's resulting type (type normalization collapses mixed|string to mixed), and suggested a special case for Ternary that inspects the if/else branches separately.

The fix

In src/Rules/FunctionCallParametersCheck.php, when an argument is a Ternary and its whole resulting type collapsed to mixed, I now inspect each branch type separately (resolved in its condition-narrowed scope, with nested ternaries flattened) and report any branch the parameter doesn't accept.

The crucial subtlety I found during testing: a naive branch-by-branch check produces false positives. The existing bug-6249 test passes an ArrayIterator<mixed> branch into a Traversable<EntityInterface> parameter — that's a legitimate normalization (the mixed gets absorbed into a usable Traversable<T>), not a bug. The distinguishing signal is exactly what the issue thread describes: the real bug is when normalization destroys all information by collapsing to plain mixed. So I gated the branch inspection on $argumentValueType instanceof MixedType, which:

  • catches #11281 (collapses to mixed), and
  • skips bug-6249 (collapses to Traversable<T>), avoiding the false positive.

Because the branch check only runs when the regular check passed, there's no risk of duplicate errors.

Verification

  • Confirmed the original reproducer now reports Parameter #1 $i ... expects int, string given on the offending line, and that valid cases (numeric-coercible branches, both-int branches, mixed parameter) stay clean.
  • Added tests/PHPStan/Rules/Functions/data/bug-11281.php + testBug11281 covering the direct case and a nested ternary; verified it fails before the source fix and passes after.
  • make phpstan → no errors. Full make tests → 17486 tests pass (only skips). The shared check is used by function/method/static-call rules, all of which pass.

The commit is pushed to the branch. The result will be posted to the PR separately.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR triggers a

Parameter #2 $string of function pg_escape_bytea expects string, resource given.

false-positive error on line 23 in

<?php

declare(strict_types=1);

namespace Bug11281b;

use PgSql\Connection as PgSqlConnection;

use function is_resource;
use function pg_escape_bytea;
use function stream_get_contents;

final class Statement
{
	public function __construct(
		private readonly PgSqlConnection $connection,
	) {
	}

	public function execute(mixed $value): void
	{
		pg_escape_bytea($this->connection,
			is_resource($value)
				? stream_get_contents($value)
				: $value
		);
	}
}

…ction

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 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed staabm's review. Here's a summary of what I did.

The review

The reviewer reported that this PR's ternary branch-inspection introduced a false positive:

Parameter #2 $string of function pg_escape_bytea expects string, resource given.

on the line ? stream_get_contents($value) in:

pg_escape_bytea($this->connection,
    is_resource($value)
        ? stream_get_contents($value)
        : $value
);

Root cause

I traced the false positive into getTernaryBranchTypes(). It resolved the else branch with $scope->filterByFalseyValue($ternary->cond). For is_resource() — whose stub only declares @phpstan-assert-if-true =resource $value (an asymmetric assertion) — the falsey path mis-narrows $value to resource instead of the mixed the ternary actually produces. (I confirmed this empirically: if (!is_resource($value)) and the real ternary both yield mixed, but filterByFalseyValue(is_resource($value)) yields resource.) The bad resource type was then checked against the string parameter and reported.

So the bug was the branch-scope re-derivation diverging from how the ternary's type is genuinely computed — not a fundamental flaw in inspecting branches.

The fix

Resolve the else branch with filterByTruthyValue(new Expr\BooleanNot($cond)) instead of filterByFalseyValue($cond). This mirrors exactly how PHPStan's own TernaryHandler::specifyTypes() models the else branch (it builds new BooleanAnd(new BooleanNot($cond), $else)), routing through the well-tested truthy specification path. The else branch now matches the type the ternary actually produces (mixed, which is accepted), so the false positive disappears while #11281 stays fixed.

Verification

  • Confirmed the reviewer's reproducer now reports no error, and that the original #11281 reproducer still reports expects int, string given.
  • Added the reviewer's case as a regression test (falsePositive() in data/bug-11281.php) — verified it produces no error while the two true positives remain (lines updated to 18/35 after the required // lint >= 8.0 comment).
  • make phpstan → no errors. Full make tests → 17486 tests pass (96 skipped), including the function/method/static-call rule suites that share this check.

The commit is pushed to the branch.

Comment thread tests/PHPStan/Rules/Functions/data/bug-11281.php
Comment thread tests/PHPStan/Rules/Functions/data/bug-11281.php
…alse

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

array_key_exists + ternary cause an improper type evaluation

2 participants