Skip to content

Skip @phpstan-assert, conditional return types and call effects when the nullsafe chain may short-circuit - #6486

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-23w85za
Open

phpstan-bot wants to merge 3 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-23w85za

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

@phpstan-assert-if-false Zero $this on a method called through a nullsafe chain
($lastPeriod?->price->isZero()) was applied in the whole falsey branch of the
ternary. But the falsey branch also contains the case where $lastPeriod is
null and isZero() was never called, so PHPStan concluded
$lastPeriod?->price is Zero — and therefore $lastPeriod is non-nullable,
producing a nullsafe.neverNull false positive on the next line.

The fix drops every piece of narrowing and every scope effect a call's own
declaration implies whenever the branch being described still admits the
chain's short-circuit null.

Changes

  • src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php — new
    callMayHaveBeenSkipped(): the receiver result carries a ?->, the receiver
    type contains null, and the context still admits null.
  • src/Analyser/TypeSpecifierContext.php — new falseyButNotFalse(). The
    short-circuit produces null, which is falsey but neither true nor false,
    and the existing false() / falsey() accessors cannot tell a strict false
    context from the wider falsey one.
  • src/Analyser/ExprHandler/MethodCallHandler.php and
    src/Analyser/ExprHandler/StaticCallHandler.phpspecifyTypes() gates
    type-specifying extensions, conditional-return-type narrowing and
    @phpstan-assert* on callMayHaveBeenSkipped(); processExpr() merges the
    post-argument scope with the pre-argument one, turns the argument
    VariableFlow into a choice, and stops the callee's never return /
    early-terminating configuration from marking the statement terminating.
  • src/Analyser/ExprHandler/PropertyFetchHandler.php,
    src/Analyser/ExprHandler/StaticPropertyFetchHandler.php,
    src/Analyser/ExprHandler/ArrayDimFetchHandler.php — the same merge + flow
    choice for the dynamic sub-expression that the short-circuit skips
    ($a?->b->{$name}, $a?->b::${$name}, $a?->b[$dim]).
  • src/Type/TypeCombinator.phpaddNull(never) now returns null instead of
    never.

Analogous cases fixed alongside the report:

case before after
@phpstan-assert-if-false on $this receiver narrowed non-null untouched
@phpstan-assert-if-false on a parameter parameter narrowed untouched
plain @phpstan-assert at statement level subject narrowed untouched
same three through $a?->b::c() narrowed untouched
conditional return type ($x is int ? true : false) $x narrowed to mixed~int in the falsey branch mixed
@param-out applied unconditionally merged with the skipped world
@phpstan-self-out applied unconditionally merged with the skipped world
@return never callee next statement reported unreachable reachable
side effects in arguments $y treated as defined "might not be defined"
side effects in a dynamic property name / static property name / array dimension same same

Probed and found already correct, so no change and no test kept: the
assert-if-true / truthy branch and the strict === false context (both
genuinely rule the short-circuit out), FuncCall and NewHandler assertions
(neither is a link in a nullsafe chain), and direct $a?->foo() narrowing,
which NullsafeMethodCallHandler already decomposes into
($a !== null) && $a->foo().

Root cause

A nullsafe chain short-circuits the entire chain, so a plain ->, :: or
[] link written on top of a ?-> may never be evaluated. NullsafeMethodCall
/ NullsafePropertyFetch handle this for the link they own — the former even
decomposes into a conjunction — but the plain handlers stacked above them only
propagated the short-circuit into the expression's type
(containsNullsafe + TypeCombinator::addNull). Everything else they derived
from the callee ran as if the call had definitely happened:

  • narrowing: MethodCallHandler::specifyTypes() /
    StaticCallHandler::specifyTypes() (extensions, conditional return types,
    asserts),
  • scope: @param-out, @phpstan-self-out, receiver invalidation, argument
    evaluation,
  • control flow: never return types and early-terminating calls.

For narrowing the deciding question is whether the branch admits null:
truthy contexts and a strict false context exclude it (so they keep full
precision), the wider falsey context and the statement context do not. For the
scope and flow effects the question is unconditional: if the chain may
short-circuit, the world where it did must be merged back in.

TypeCombinator::addNull() was a second instance of the same blind spot: its
guard $nullType->isSuperTypeOf($type)->no() is false for never (as it is for
every type, null being a supertype of never), so the short-circuit's null
was swallowed for never-returning callees.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15016.php — the playground reproducer plus
    assertType() coverage of the falsey branch, the strict === false branch,
    the assert-if-true branch, the statement-level @phpstan-assert, parameter
    asserts through both -> and ::, a conditional return type, and a
    non-nullable receiver (which must keep narrowing). 8 assertions fail without
    the fix.
  • tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest::testBug15016
    with data/bug-15016.php — the reported nullsafe.neverNull false positive.
  • tests/PHPStan/Analyser/nsrt/nullsafe-short-circuit-effects.php
    @param-out through -> and ::, @phpstan-self-out, never return types,
    each paired with the non-nullable-receiver control case.
  • tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest::testNullsafeShortCircuitTerminating
    — the never-returning callee no longer kills the following statement, while
    the non-nullsafe and non-nullable-receiver variants still do.
  • tests/PHPStan/Rules/Variables/DefinedVariableRuleTest::testNullsafeShortCircuitArgs
    — argument / dynamic-name / array-dimension side effects are now reported as
    possibly undefined for all five link kinds.
  • tests/PHPStan/Type/TypeCombinatorTestaddNull(never) is null.

Fixes phpstan/phpstan#15016
Fixes phpstan/phpstan#12925

Comment thread src/Analyser/TypeSpecifierContext.php

@VincentLanglet VincentLanglet 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.

Add a non regression bug for phpstan/phpstan#12925 too

@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — added the regression test for phpstan/phpstan#12925 and pushed it.

tests/PHPStan/Analyser/nsrt/bug-12925.php (commit 2df59b08d) — the OP's playground snippet (118bf08a…) copied close to verbatim, with \PHPStan\dumpType() swapped for assertType():

$c = rand() ? new C(new Decimal((string)rand())) : null;

assertType('Bug12925\C|null', $c);
echo $c?->p->isZero() ? 'Free' : 'Buying';
assertType('Bug12925\C|null', $c);

isZero() carries both @phpstan-assert-if-true self<true> $this and @phpstan-assert-if-false self<false> $this; before the fix the if-false assert was applied in the ternary's falsey branch, which also contains the short-circuited world where $c is null, so both branches narrowed $c to non-null and the post-ternary type collapsed to C.

Verification, per the stash-the-fix protocol:

  • With the fix reverted (git show 538518c90 -- src | git apply -R): line 25 fails with Expected: Bug12925\C|null / Actual: Bug12925\C — the exact symptom from the issue. Line 23 passes, confirming the failure is the narrowing, not the setup.
  • With the fix restored: passes.
  • make tests — OK, 21958 tests, 97570 assertions, 96 skipped (pre-existing skips).
  • make phpstan — no errors.

Two notes:

  • The file lives in nsrt/ because the bug is a wrong inferred type; no rule reports an error on this snippet, so there's no rule-test counterpart to add (the nullsafe.neverNull false positive from the sibling issue #15016 already has one in NullsafePropertyFetchRuleTest).
  • nsrt/ is excluded from phpcs (phpcs.xml:127), so the reproducer's original formatting — single-line constructor, promoted property — is kept verbatim rather than reformatted.

I left the two earlier review comments on this PR (whether falseyButNotFalse() can be expressed with falsey()/false(), and shortening the callMayHaveBeenSkipped() docblock) untouched, since this round only asked for the #12925 test — say the word if you want those addressed in this push too.

Comment thread src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php Outdated
Comment thread src/Analyser/TypeSpecifierContext.php Outdated
phpstan-bot and others added 3 commits September 20, 2026 23:01
…n the nullsafe chain may short-circuit

* `DefaultNarrowingHelper::callMayHaveBeenSkipped()` decides whether a plain
  call whose receiver chain contains a `?->` may not have run at all in the
  branch being narrowed. `MethodCallHandler::specifyTypes()` and
  `StaticCallHandler::specifyTypes()` gate all callee-derived narrowing
  (type-specifying extensions, conditional return types, `@phpstan-assert*`)
  on it, so the short-circuit's `null` no longer gets narrowed as if the
  method had returned `false`.
* Added `TypeSpecifierContext::falseyButNotFalse()`: the short-circuit yields
  `null`, which is falsey but neither `true` nor `false`, so a strict
  `=== false` context keeps its full narrowing while the wider falsey branch
  and the statement (`null`) context lose it.
* `MethodCallHandler`/`StaticCallHandler` now merge the post-argument scope
  with the pre-argument one and make the argument flow a `VariableFlow::choice`
  when the chain may short-circuit, so `@param-out`, `@phpstan-self-out`,
  expression invalidation and argument side effects no longer apply
  unconditionally, and a `never`-returning callee no longer marks the following
  statement unreachable.
* Same treatment for the dynamic sub-expression of the sibling fetches:
  `PropertyFetchHandler` (`$a?->b->{$name}`), `StaticPropertyFetchHandler`
  (`$a?->b::${$name}`) and `ArrayDimFetchHandler` (`$a?->b[$dim]`).
* `TypeCombinator::addNull()` returned `never` for `never` because `null` is a
  supertype of `never`; it now returns `null`, which is what made the
  short-circuit of a `never`-returning call invisible.
* Probed and found already correct: the `assert-if-true` / truthy branch,
  `FuncCall` (a function call is never a link in a nullsafe chain) and
  `NewHandler` asserts, and direct `$a?->foo()` narrowing (decomposed into a
  conjunction by `NullsafeMethodCallHandler`).
…@phpstan-assert-if-false`

The `@phpstan-assert-if-false self<false> $this` of a method called through
`$c?->p->isZero()` was applied in the falsey branch of the ternary, which also
covers the short-circuited world where `$c` is null - so after the ternary `$c`
was narrowed to `C` instead of staying `C|null`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-23w85za branch from 2355d10 to 9548a55 Compare September 20, 2026 21:01

@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.

I think this makes sense. as long as CI is red I find it hard to reason about such changes tbh.

we should at least locally verify that this change does not regress performance (run phpbench locally)

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.

@phpstan-assert-if-false applied to wrong element Narrowing a property using @phpstan-assert causes the parent value to narrow.

3 participants