Skip to content

Track ExpressionTypeResolverExtension declines per type flavour in ExpressionResult - #6831

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

An ExpressionTypeResolverExtension whose answer depends on the receiver's type could be skipped for one type flavour after it declined the other. In the reported case, $order is Order only by PHPDoc. The extension answered for the PHPDoc scope and declined for the native scope (the receiver is mixed there). After that ExpressionResult stopped asking it at all, so foreach ($order->lines as $line) typed $line as mixed and not as Line.

This PR records extension declines separately for each flavour.

Changes

  • src/Analyser/ExpressionResult.php: new $nativeExtensionsDeclined flag (constructor parameter, defaults to false), next to the existing $extensionsDeclined, which now means "declined on the PHPDoc flavour". consultExpressionTypeResolverExtensions() chooses the flag from $readScope->nativeTypesPromoted. getNativeType() checks the native flag. finalize(), the from-scope copy and onNonNullabilityDevicedScopes() carry both flags over.
  • turbo-ext/src/ExpressionResult.cpp + regenerated turbo-ext/src/generated/ExpressionResult.h: the same change in the native mirror (new slot, constructor argument, per-flavour slot selection in consultExpressionTypeResolverExtensions(), getNativeType() guard, copies).
  • Analogous cases probed: the other per-flavour memo slots in ExpressionResult (cachedType/cachedNativeType, resolvedType/resolvedNativeType, projectedType/projectedNativeType) were already split per flavour. The decline flag was the only state the two flavours shared.

Root cause

The one-round "every extension declined" cache treated a decline as a fact about the expression alone. But extensions are asked with a PHPDoc-flavoured and a native-flavoured scope, and their answer can depend on that scope (the receiver's type differs between the two). A decline on one flavour blocked the other flavour, in both directions:

  • a native decline hid the PHPDoc answer (the reported bug), and
  • a PHPDoc decline (asked first by getType()) hid a native answer.

Keeping one flag per flavour keeps the performance benefit (each flavour still asks only once) without mixing the two.

Test

  • tests/PHPStan/Analyser/data/bug-15496.php with Bug15496ExpressionTypeResolverExtension, registered in expression-type-resolver-extension.neon and run by ExpressionTypeResolverExtensionTest:
    • the reproducer from the issue (receiver typed only by PHPDoc → Line in the PHPDoc flavour; a natively typed receiver → Line in both flavours);
    • the mirror case: an extension that declines on the PHPDoc flavour and answers on the native one (assertNativeType('Bug15496\Line', ...)).
  • Both cases fail without the fix and pass with it. With the PHP change stashed and the turbo extension loaded, they also pass, which shows the native mirror carries the fix on its own. make tests passes with and without the extension loaded, and turbo smoke.php, signature-parity.php and side-by-side.php pass.

Fixes phpstan/phpstan#15496

🤖 Generated with Claude Code

…`ExpressionResult`

- ExpressionResult kept a single `$extensionsDeclined` flag. Once every
  extension returned null on one flavour (e.g. native, where a
  PHPDoc-only typed receiver is `mixed`), extensions were never asked again
  on the other flavour, so a foreach over the expression fell back to the
  property's native `array` type and the value became `mixed`.
- Add `$nativeExtensionsDeclined`; consultExpressionTypeResolverExtensions()
  picks the flag from the read scope's `nativeTypesPromoted`, so
  getType(), getNativeType() and getTypeOnScope() each honour only the
  decline of their own flavour. The flag is carried through finalize(),
  the from-scope copy and onNonNullabilityDevicedScopes().
- Covers both directions: a native decline no longer hides the PHPDoc
  answer, and a PHPDoc decline no longer hides the native answer.
- Ported to the turbo-ext mirror (turbo-ext/src/ExpressionResult.cpp,
  regenerated turbo-ext/src/generated/ExpressionResult.h).
- Probed the other per-flavour memo slots of ExpressionResult (cachedType /
  cachedNativeType, resolvedType / resolvedNativeType, projectedType /
  projectedNativeType): they are already split per flavour. The decline
  flag was the only state shared between them.
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.

ExpressionTypeResolverExtension is skipped for the PHPDoc type after it declined the native type (foreach value becomes mixed)

1 participant