Skip to content

Nullsafe call compared to a bool literal no longer narrows its receiver to null - #13602

Merged
orklah merged 1 commit into
vimeo:masterfrom
0xABCD01:fix/13066-falsy-nullsafe-narrowing
Nov 14, 2026
Merged

orklah merged 1 commit into
vimeo:masterfrom
0xABCD01:fix/13066-falsy-nullsafe-narrowing

Conversation

@0xABCD01

Copy link
Copy Markdown
Contributor

Fixes #13066

#12739 fixed the bare-condition variants of #13066 (plain falsy condition, early return, ternary). The strict-comparison variant is still broken on master, because the Identical/NotIdentical path never got the same treatment:

function identicalFalse(?Box $box): void {
    if ($box?->check() === false) {
        // $box was narrowed to null here
    }
}

A false result cannot come from a null receiver (a null receiver makes the call evaluate to null), yet the equality assertions negated the receiver assertion scraped for the call and narrowed $box to null. Sibling branches were wrong as well: the else branch of === false, the else branch of === true, and both !== forms (one of them fully inverted). Loose ==/!= comparisons were already correct and are not touched.

Fix

FormulaGenerator::getFormula now builds the clauses for an Identical/NotIdentical comparison between a NullsafeMethodCall and a bool literal directly, over the call's result and its receiver — the same clause pattern #12739 used for the bare-condition case:

  • === true: [result truthy] + [result falsy ∨ receiver !null]
  • === false: [result falsy] + [result truthy ∨ receiver !null]
  • !== false: [result truthy ∨ receiver null]
  • !== true: [result falsy ∨ receiver null]

The literal-on-the-left form is handled symmetrically. A receiver without a var id falls back to the previous behaviour. Loose comparisons, === null/!== null, nullsafe property fetches, and assignments inside comparisons are out of scope and unchanged.

Files changed: src/Psalm/Internal/Algebra/FormulaGenerator.php (+108), tests/TypeReconciliation/TypeAlgebraTest.php (+45).

Tests

New case nullsafeCallComparedToBoolLiteralDoesNotNarrowReceiver in TypeAlgebraTest — five functions covering ===/!== against true/false in both operand orders, checked with @psalm-check-type-exact:

Known precision limit

In the === false branch the receiver ends up as Box|null, not Box: the disjunctive clause cannot force the receiver when the call's result ref is unknown to the context. That matches the precision accepted in #12739 and what the reporter expects in #13066. Master gives null there, which is wrong outright.

Assistance Disclosure

AI assistance was used for research, re-testing the reported variants, and drafting this description. The fix and the test results were verified locally.

…ral does not narrow its receiver to null

A nullsafe method call used as a bare condition is handled by the
formula generator, but comparing its result strictly against true or
false fell through to the equality assertions, which negated (or used
raw) the receiver assertion scraped for the call. That narrowed the
receiver to null on the falsy side of the comparison, e.g. inside
$foo?->check() === false, where a false result even proves the
receiver was not null.

Build the clauses for Identical/NotIdentical against a bool literal
directly, over the call's result and its receiver, the same way the
bare-condition case does.

Fixes vimeo#13066
@github-actions

Copy link
Copy Markdown
Contributor

✅ Performance: unchanged · Issues: decreased (+0 −16)

Project Memory (MB) Wall (s) Issues Weighted
✅ laravel 1 844 57.0 $\color{gray}{\textsf{+1.2\%}}$ 26 100 50 914
✅ symfony 4 016 168.2 $\color{gray}{\textsf{-0.5\%}}$ 50 996 $\color{gray}{\textsf{+0}}$ $\color{green}{\textsf{-16}}$ 100 187 $\color{green}{\textsf{-58}}$
✅ doctrine-orm 432 18.4 $\color{gray}{\textsf{-1.2\%}}$ 3 487 5 504
✅ psl 654 22.9 $\color{gray}{\textsf{-1.1\%}}$ 1 336 5 245
✅ phpunit 510 16.6 $\color{gray}{\textsf{+0.2\%}}$ 1 610 1 220
✅ php-parser 246 16.4 $\color{gray}{\textsf{-0.3\%}}$ 17 165 20 605
✅ tempest 929 28.4 $\color{gray}{\textsf{+0.3\%}}$ 4 911 9 159
✅ amphp-amp 61 2.1 $\color{gray}{\textsf{-2.8\%}}$ 32 79
✅ webmozarts-assert 278 5.1 $\color{gray}{\textsf{+3.5\%}}$ 577 2 714
✅ guzzle-promises 56 2.1 162 405
✅ monolog 163 5.6 $\color{gray}{\textsf{-0.5\%}}$ 915 2 177
✅ drupal 5 219 160.9 $\color{gray}{\textsf{+1.1\%}}$ 98 456 160 734
Total 14 408 503.9 $\color{gray}{\textsf{+0.3\%}}$ 205 747 $\color{gray}{\textsf{+0}}$ $\color{green}{\textsf{-16}}$ 358 943 $\color{green}{\textsf{-58}}$
How to read this table
  • Memory: Psalm's own peak figure, threads=1. More than +5 % fails, unless the PR changes composer.json.
  • Wall: base and head run on the same runner; ±10 % is noise. Above +20 % warns, never fails. re-run: a reversed second run was taken and the faster kept.
  • Issues: head count, then $\color{red}{\textsf{+added}}$ $\color{green}{\textsf{-removed}}$ against base.
  • Weighted: issues weighted by error level (levels 1–8 as is, always-errors 10, feature-specific and purity suggestions 0). The projects' own code is pinned and their dependencies resolve identically for base and head, so a change means Psalm reports more, fewer or differently serious issues. A rise is not necessarily a regression.
  • Total: sum over the projects compared on both sides. Thresholds apply per project, not to the total.
  • Colors: $\color{red}{\textsf{red}}$ past a fail or warn threshold (or more issues), $\color{green}{\textsf{green}}$ an improvement past noise (or fewer issues), $\color{gray}{\textsf{gray}}$ noise.
  • Performance: regressed if a head run crashed or timed out or a memory or wall delta is red, else improved if one is green, else unchanged. Its icon is the check result: ❌ fail · ⚠️ warn · ✅ pass; the icon next to each project is that project's result, 💥 = not comparable: base crashed or timed out (counts as ⚠️).
  • Issues (heading): the total weighted change, increased · decreased · unchanged, followed by the issues added and removed across the projects, e.g. increased (+175 −128). If the weighted total is zero but issues were added or removed (those weighted 0 changed, or changes offset), it reads changed (+175 −128, weighted unchanged). Informational, never fails the check.
✅ symfony: issues +0 −16

Memory 4 016 → 4 016 MB (<0.1 %) · Wall 169.1 → 168.2 s (−0.5 %)

Weighted 100 245 → 100 187 (−58, −0.06 %): L1 −2 · L4 −14

Issue type added removed
MixedMethodCall 0 2
RedundantCondition 0 6
TypeDoesNotContainNull 0 6
TypeDoesNotContainType 0 2

Removed (16)

  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:268 RedundantCondition (error): Type Symfony\Component\Mailer\Header\TrackingHeader for $tracking is never null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:268 TypeDoesNotContainNull (error): Symfony\Component\Mailer\Header\TrackingHeader does not contain null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 RedundantCondition (error): Type Symfony\Component\Mailer\Header\TrackingHeader for $tracking is never null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 TypeDoesNotContainNull (error): Cannot resolve types for $tracking - Symfony\Component\Mailer\Header\TrackingHeader does not contain null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 RedundantCondition (error): Type Symfony\Component\Mailer\Header\TrackingHeader for $tracking is never null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 TypeDoesNotContainNull (error): Symfony\Component\Mailer\Header\TrackingHeader does not contain null
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 TypeDoesNotContainType (error): Type null for $tracking is always null, so its negation can never be true
  • src/Symfony/Component/Mailer/Bridge/Azure/Transport/AzureApiTransport.php:272 MixedMethodCall (error): Cannot determine the type of $tracking when calling method getClicks
  • src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php:206 RedundantCondition (error): Type Symfony\Component\Mailer\Header\TrackingHeader for $tracking is never null
  • src/Symfony/Component/Mailer/Bridge/Brevo/Transport/BrevoApiTransport.php:206 TypeDoesNotContainNull (error): Symfony\Component\Mailer\Header\TrackingHeader does not contain null
  • … and 6 more

head 69be35a · base 0d5bbff (merge-base) · workflow run

@orklah orklah added the release:fix The PR will be included in 'Fixes' section of the release notes label Nov 14, 2026
@orklah
orklah merged commit b7d9e03 into vimeo:master Nov 14, 2026
73 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:fix The PR will be included in 'Fixes' section of the release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A falsy nullsafe method call narrows its base to null

2 participants