Skip to content

Preserve maybe-certainty only for Variable when narrowing the base of ?->, ??, isset() and empty() - #6109

Merged
staabm merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-75untdb
Jul 27, 2026
Merged

Preserve maybe-certainty only for Variable when narrowing the base of ?->, ??, isset() and empty()#6109
staabm merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-75untdb

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

getAcme()?->foo(); twice in a row reported Cannot call method foo() on Acme|null. on the second occurrence, even though each call is guarded by ?->. Assigning the call result to a variable first was a workaround.

The cause was that the non-null narrowing PHPStan applies to the left side of a nullsafe operator was being stored with Maybe certainty, which makes it invisible to Scope::getType() for anything that is not a Variable. The fix restricts the certainty preservation to Variable nodes, which fixes the reported case plus a whole family of related false positives.

Changes

  • src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php: ensureShallowNonNullability() now only copies an existing Maybe certainty onto the narrowed expression when that expression is a PhpParser\Node\Expr\Variable; every other expression is specified with Yes.
  • tests/PHPStan/Rules/Methods/data/bug-15002.php + testBug15002() in tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php.
  • tests/PHPStan/Rules/Properties/data/bug-15002.php + testBug15002() in tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php.

Analogous cases probed and covered by the same fix (all were failing before, all pass now):

  • ?-> method call vs. ?-> property fetch (NullsafeMethodCallHandler / NullsafePropertyFetchHandler).
  • Nullsafe chains ending in a method call: getAcme()?->prop?->prop?->foo() and getAcme()?->prop?->prop?->get().
  • Receivers other than a plain function call: $o->get()?->foo(), Acme::$stat?->foo(), array-dim base.
  • Sources of Maybe certainty other than the nullsafe scope merge itself — a narrowing of the same expression inside a nested if, a ternary branch, a match arm, a switch case, a try/catch, for/foreach/while/do-while bodies, a closure body, and two nullsafe calls in the same argument list.
  • ??, isset() and empty() go through the same helper (CoalesceHandler, IssetHandler, EmptyHandler), so they are fixed by construction; probes with repeated getP()->prop ?? null, isset(getP()->prop->prop) and empty(...) behave correctly.
  • Probed and found already correct (no change needed): getAcme()?->prop repeated on its own — NullsafePropertyFetchHandler does not merge with the pre-nullsafe scope, so no Maybe entry was produced there.

Root cause

NonNullabilityHelper::ensureShallowNonNullability() narrows the base expression of ?-> / ?? / isset() / empty() to its non-null form and records the original state so it can be reverted afterwards. Since e300c7eae it also carried over the expression's existing certainty:

$certainty = TrinaryLogic::createYes();
if (!$hasExpressionType->no()) {
    $certainty = $hasExpressionType;
}

That was introduced so that a maybe-defined variable used as $a?->foo() still gets reported by DefinedVariableRule. For variables it is harmless, because VariableHandler::resolveType() goes through MutatingScope::getVariableType(), which returns the holder's type regardless of certainty.

For every other expression it is destructive: ScopeOps::expressionTypeByKey() — the path MutatingScope::resolveType() uses for non-Variable nodes — only returns a tracked type when the holder's certainty is Yes. With Maybe, the Acme|null → Acme narrowing is stored but never read back, so the rule sees Acme|null again.

getAcme()?->foo() produced exactly such a Maybe entry: NullsafeMethodCallHandler::processExpr() merges the post-call scope (which has a getAcme() entry) with the pre-call scope (which has none) to model argument short-circuiting, and a merge of "present" with "absent" yields Maybe. The next getAcme()?->foo() then hit the broken branch. The same Maybe can arrive from any scope merge — an if/else, a loop body, a ternary, a match arm, try/catch — which is why the bug was much wider than the reported snippet.

The fix keeps the certainty only where it is both meaningful and lossless (Variable), and specifies Yes everywhere else.

Test

  • CallMethodsRuleTest::testBug15002() over tests/PHPStan/Rules/Methods/data/bug-15002.php — the verbatim reproducer from the issue's playground plus 14 analogous cases. Without the fix it reports 15 method.nonObject false positives; with the fix it analyses clean.
  • AccessPropertiesRuleTest::testBug15002() over tests/PHPStan/Rules/Properties/data/bug-15002.php — the property-fetch counterparts. Without the fix it reports 2 property.nonObject false positives; with the fix it analyses clean.

Both were verified to fail on the unpatched source. make tests and make phpstan are green. (make name-collision fails on tests/PHPStan/Build/data/final-class-rule-pipe.php both with and without this change — pre-existing, unrelated.)

Fixes phpstan/phpstan#15002

…of `?->`, `??`, `isset()` and `empty()`

- `NonNullabilityHelper::ensureShallowNonNullability()` copied the existing
  `Maybe` certainty of the narrowed expression onto the specified non-null type.
  For non-`Variable` expressions a `Maybe` certainty makes the narrowed type
  invisible to `Scope::getType()` (`ScopeOps::expressionTypeByKey()` only returns
  types of holders whose certainty is `Yes`), so the narrowing was silently
  thrown away.
- Certainty is now only kept for `Variable` nodes, where `Maybe` still carries
  the "might not be defined" meaning consumed by `DefinedVariableRule` and where
  the narrowed type is still returned (variables resolve through
  `getVariableType()`, which ignores certainty).
- Fixes the whole family at once: `NullsafeMethodCallHandler`,
  `NullsafePropertyFetchHandler`, `CoalesceHandler`, `IssetHandler` and
  `EmptyHandler` all go through the same helper.
- Regression tests cover `?->` on repeated function calls, chained
  `?->prop?->prop?->foo()`, method-call and static-property receivers, and a
  `Maybe` certainty arriving from an unrelated scope merge (if/else, ternary,
  match, switch, try/catch, for/foreach/while/do-while, closure, `??`, multiple
  nullsafe calls in one argument list).
Comment on lines +30 to +34
function staticProperty(): void
{
$a = Acme::$stat?->foo();
$b = Acme::$stat?->foo();
}

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.

static property fetches need to be tested by AccessStaticPropertiesRuleTest and static method calls by CallStaticMethodsRuleTest.

these tests can reference the existing file tests/PHPStan/Rules/Methods/data/bug-15002.php instead of creating another copy

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed as 2d7a223e8.

What changed

  • AccessStaticPropertiesRuleTest::testBug15002() — analyses the existing tests/PHPStan/Rules/Methods/data/bug-15002.php (via /../Methods/data/...), so the Acme::$stat?->foo() cases are actually checked by the rule that owns static property fetches. No copy of the data file.
  • CallStaticMethodsRuleTest::testBug15002() — same file, so static method calls are checked by their own rule.
  • tests/PHPStan/Rules/Methods/data/bug-15002.php — added Acme::create(): ?Acme plus staticMethodCall(), staticMethodCallChained() and staticPropertyChained(), so CallStaticMethodsRule has static calls to check rather than an empty file.

One thing worth flagging: the static-base cases do not reproduce the bug. Re-running with the source fix reverted, the false positives land only on function-call bases (getAcme()?->foo()); lines 37/43/49/55 (Acme::$stat?->…, Acme::create()?->…) are clean both before and after the fix — the count stays at 15 either way. So the two new tests, and the static cases in the data file, are coverage that these rules stay quiet on nullsafe bases; they are not regression tests for #15002 itself. The regression protection still comes solely from CallMethodsRuleTest/AccessPropertiesRuleTest.

Verification: the 15-error pre-fix failure was reconfirmed by checking out fdedb4565^'s NonNullabilityHelper.php. make tests (17632 tests, 97 skipped) and make phpstan are green.

Static property fetches are checked by AccessStaticPropertiesRule and
static method calls by CallStaticMethodsRule, neither of which was run
over the data file. Both test classes now analyse the existing
tests/PHPStan/Rules/Methods/data/bug-15002.php instead of duplicating it,
and the data file gained repeated `Acme::create()?->foo()` cases so the
static method call rule has something to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm merged commit e6d7b5b into phpstan:2.2.x Jul 27, 2026
657 of 658 checks passed
@staabm
staabm deleted the create-pull-request/patch-75untdb branch July 27, 2026 11:31
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.

Bug: False positive "Cannot call method X() on Y|null" when using nullsafe operator on repeated function calls

3 participants