Skip to content

Preserve variable certainty when narrowing the base of a nullsafe operator#5870

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7sxuwre
Open

Preserve variable certainty when narrowing the base of a nullsafe operator#5870
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7sxuwre

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan failed to report Variable $x might not be defined. for the base of a nullsafe access ($x?->foo, $x?->bar()) when the base variable was only maybe-defined and its type contained null (e.g. assigned stdClass|null inside an if). The bare $x->foo / $x equivalents were reported correctly, so the nullsafe operator was silently suppressing a real undefined-variable warning.

Changes

  • src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php: in ensureShallowNonNullability(), when the base type contains null and is narrowed, specify the narrowed type with the already-computed $certainty instead of TrinaryLogic::createYes(). This keeps a maybe-defined variable's Maybe certainty intact so the undefined-variable check still fires.
  • src/Analyser/NodeScopeResolver.php: lookForExpressionCallback() now also recurses into NullsafeMethodCall->var, so the nullsafe method-call base is correctly registered as an allowed-undefined expression inside ?? and empty() — matching the existing handling of NullsafePropertyFetch and matching PHP's runtime behavior. Regular MethodCall is deliberately not added because PHP does emit an undefined-variable warning for $x->bar() ?? 0.

Root cause

The nullsafe handlers (NullsafePropertyFetchHandler, NullsafeMethodCallHandler) call ensureShallowNonNullability() to strip null from the base before evaluating the inner PropertyFetch/MethodCall. When the base type actually contained null, that method re-specified the base expression with TrinaryLogic::createYes(), overwriting the variable's Maybe certainty with Yes. As a result DefinedVariableRule saw a definitely-defined variable and stayed silent. When the base type did not contain null, the method returned early without re-specifying, which is why the non-nullable case already worked. The fix uses the certainty the method already computes (and stores for the later revert) so narrowing never changes definedness.

A second, related axis is which left-operand shapes ??/isset()/empty() suppress undefined-variable warnings for. The allowed-undefined recursion handled PropertyFetch and NullsafePropertyFetch bases but not NullsafeMethodCall bases, so once certainty was preserved, $x?->bar() ?? 0 started reporting even though PHP does not warn there. Adding NullsafeMethodCall to the recursion mirrors PHP's behavior across the whole bare / ?? / isset / empty × prop / method matrix.

Test

  • tests/PHPStan/Rules/Variables/data/bug-7291.php + testBug7291 in DefinedVariableRuleTest: covers the full matrix — bare $x?->foo, $x?->bar(), $x?->bar()?->foo, and a non-nullable base all warn; $x?->foo ?? 0, $x?->bar() ?? 0, isset($x?->foo), empty($x?->foo), empty($x?->bar()) do not. Each expectation was verified against real PHP 8.4 runtime behavior.
  • tests/PHPStan/Analyser/nsrt/bug-7291.php (pre-existing): confirms the variable's type and Maybe certainty are preserved before and after a nullsafe access.

Fixes phpstan/phpstan#7291

…rator

- `NonNullabilityHelper::ensureShallowNonNullability()` re-specified the expression with `TrinaryLogic::createYes()` when removing null from a nullable base, wrongly upgrading a maybe-defined variable to certainly-defined. Pass the already-computed `$certainty` instead, so `DefinedVariableRule` still reports the possibly-undefined base of `$x?->foo` / `$x?->bar()` when the base type contains null.
- `NodeScopeResolver::lookForExpressionCallback()` now recurses into `NullsafeMethodCall->var` so the nullsafe method-call base is marked allowed-undefined inside `??` / `empty()` (matching `NullsafePropertyFetch`), since PHP does not warn for `$x?->bar() ?? 0`. Regular `MethodCall` is intentionally left out because PHP does warn for `$x->bar() ?? 0`.

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

[...]

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of how I addressed the review.

The review

VincentLanglet noted the baseline "is not generated in the same order," with a diff showing four Nette\Schema entries in ContainerFactory.php appearing in one order on one side and another order on the other.

Root cause (investigated, not guessed)

  • The PR itself doesn't touch the baseline. In the canonical CI environment, regenerating phpstan-baseline.neon produces zero diff — confirmed by a full regenerate-and-diff.
  • The reviewer's reordering comes from a different local Nette version: there, Statement::getEntity() returns a union, so PHPStan emits extra *.nameNotString errors. Those extra messages re-interleave the list because the baseline formatters sorted entries by message text only.
  • Since messages embed type strings that vary across PHP/dependency versions (and the very set of messages can change), message-based ordering is inherently unstable. I reproduced both the old order and the reviewer's expected order directly against the formatter.

Fix

Sort baseline entries by identifier first (stable across environments), then fall back to message — in both BaselineNeonErrorFormatter and BaselinePhpErrorFormatter. This produces exactly the reviewer's expected order and makes regeneration deterministic regardless of the type strings rendered in messages.

Two commits:

  1. Sort baseline errors by identifier before message — both formatters + their test expectations.
  2. Regenerate baseline with identifier-first orderingphpstan-baseline.neon reordered within multi-error files (110 changed lines; content unchanged, ordering only).

Verification

  • make phpstan[OK] No errors with the regenerated baseline.
  • make tests → 17387 tests OK (96 skipped, version-gated).
  • Confirmed the formatter now emits the four ContainerFactory errors in method.dynamicName, method.nameNotString, staticMethod.dynamicName, staticMethod.nameNotString order — matching the reviewer's expected side.

Pushed to create-pull-request/patch-7sxuwre.

One note worth flagging: this changes baseline ordering globally, so every user's baseline will reorder within multi-error files on their next regeneration (a one-time, content-neutral diff). That's the trade-off for stable, reproducible ordering — if the maintainers would rather keep message-primary sorting and treat the reviewer's observation as environment-specific, the formatter commit can be dropped.

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-7sxuwre branch from d6b0013 to b83a305 Compare June 14, 2026 16:28
@VincentLanglet VincentLanglet requested a review from staabm June 14, 2026 17:00
@VincentLanglet VincentLanglet self-assigned this Jun 14, 2026
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.

Report possibly-undefined variables in null-safe operator

2 participants