Skip to content

Read the native base type from native state when narrowing an expression that only has a PHPDoc holder in MutatingScope::applySpecifiedTypes() - #6808

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-4k5qdxd
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-4k5qdxd

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

With treatPhpDocTypesAsCertain: false, storing isset($s['projects']) in a variable and checking that variable later made the PHPDoc type of $s['projects'] (list<string>) show up as its native type. This caused a false function.alreadyNarrowedType for is_string($p) inside a foreach over it. This PR makes narrowing take the native base type from native state, not from the PHPDoc-derived specified type.

Changes

  • src/Analyser/MutatingScope.php (applySpecifiedTypes()): when an expression has a tracked PHPDoc type but no tracked native type, read the native base type via getCurrentTypesOfSpecifiedExpr().
  • turbo-ext/src/MutatingScope.cpp: the same change in the native mirror.
  • Analogous cases I probed. They all reach the same code path and are fixed by this change (covered in the NSRT test):
    • $has = array_key_exists(...)
    • $s['projects'] !== []
    • !is_int($s['projects']) (the sure-not / removal branch)
    • is_array($s['projects'])
    • a property fetch narrowed through $has = $this->projects !== null

Root cause

A conditional expression holder (if $has=true then $s['projects'] is list<string>) is applied by processConditionalExpressionsAfterSpecifying() to expressionTypes only, so the expression ends up with a PHPDoc holder and no native holder. In applySpecifiedTypes(), the native current type was only looked up when the PHPDoc side was untracked too. Otherwise $trackedNativeType stayed null, and all three branches used the PHPDoc-derived type as the native result:

  • sure: : $type
  • sure-not: : $newType
  • alternative terms: ?? $trackedType

The foreach's iteratee !== [] narrowing then wrote non-empty-list<string> as the native type of $s['projects']. Reading the native current type in this case fixes all three branches.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15404.php: the reproducer from the issue, the inline-isset() form, and the analogous cases above, with assertType() / assertNativeType(). Without the fix it fails at 4 sites:
    • the reported foreach
    • !== []
    • !is_int()
    • the property fetch
  • ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest::testBug15404: the reported false positive is no longer emitted. It fails without the fix.
  • make tests and make phpstan pass. The full suite also passes with the turbo extension loaded. turbo-ext/tests/smoke.php and walk-trace.php over the default corpus report identical output.

Fixes phpstan/phpstan#15404

🤖 Generated with Claude Code

…ion that only has a PHPDoc holder in `MutatingScope::applySpecifiedTypes()`

- When an expression had a Yes-certainty holder in expressionTypes but none
  in nativeExpressionTypes (e.g. `$s['projects']` placed only on the phpdoc
  side by the conditional expression of `$has = isset($s['projects'])`), the
  native result of a narrowing fell back to the phpdoc-derived specified type.
  That leaked `list<string>` into the native type through the foreach
  `iteratee !== []` narrowing, and caused a false function.alreadyNarrowedType
  with treatPhpDocTypesAsCertain: false.
- Now the native base comes from getCurrentTypesOfSpecifiedExpr() in that
  case. This covers all three branches: sure narrowing, sure-not removal,
  and alternative-form terms.
- Ported the same change to the native mirror in turbo-ext/src/MutatingScope.cpp.
- Probed the analogous entry points (array_key_exists() stored in a variable,
  `!== []`, `!is_int()`, `is_array()`, property fetch `!== null` stored in a
  variable). They all went through the same code path and are covered by the
  new NSRT test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-4k5qdxd branch from eb5aaa6 to cbd408f Compare October 27, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant