Skip to content

Fix phpstan/phpstan#8985: Expression result remembered on impure call-chains#5449

Open
phpstan-bot wants to merge 19 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-r89jstk
Open

Fix phpstan/phpstan#8985: Expression result remembered on impure call-chains#5449
phpstan-bot wants to merge 19 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-r89jstk

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Apr 13, 2026

Comment thread src/Analyser/MutatingScope.php Outdated
Comment thread src/Analyser/MutatingScope.php Outdated
Comment thread src/Analyser/MutatingScope.php Outdated
@staabm staabm force-pushed the create-pull-request/patch-r89jstk branch from 9b7fe1f to e7850f1 Compare April 14, 2026 05:49
@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 14, 2026

add tests for https://phpstan.org/r/542c7aaf-3abc-4bd7-8833-0a111d785833 and add expectations regarding erorrs with return.type identifier

@staabm staabm force-pushed the create-pull-request/patch-r89jstk branch from e7850f1 to f24ac4b Compare April 19, 2026 10:36
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix the implementation so newly added tests, which currently fail get green.
don't change the tests

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The fix uses AST node identity to distinguish between:

  • @var annotations — stored type's Expr is the same object as the expression being resolved (same AST node), so the stored type is used
  • assert() narrowing — stored type's Expr is a different object with the same string key (different new instantiation), so the stored type is skipped

All 11875 tests pass and make phpstan reports no errors.

@staabm staabm requested a review from VincentLanglet April 19, 2026 11:21
Comment thread src/Analyser/MutatingScope.php Outdated
&& !$node instanceof Expr\Closure
&& !$node instanceof Expr\ArrowFunction
&& $this->hasExpressionType($node)->yes()
&& (!$this->expressionHasNewInChain($node) || $this->expressionTypes[$exprString]->getExpr() === $node)
Copy link
Copy Markdown
Contributor

@staabm staabm Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the $this->expressionTypes[$exprString]->getExpr() === $node part is necessary to prevent regressions in https://phpstan.org/r/91d74faf-9234-404b-932f-cea39c881063

(so inline-phpdocs are still applied)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 11,875 tests pass and make phpstan reports no errors.

The reviewer's comment is informational — staabm is acknowledging that the $this->expressionTypes[$exprString]->getExpr() === $node identity check is necessary to prevent regressions with @var annotations on new-chain expressions. This check distinguishes between:

  • Same AST node (e.g., @var annotation set on this exact expression) — stored type is used
  • Different AST node with same string key (e.g., assert() narrowing from a different new instantiation) — stored type is skipped

The implementation is already correct and all tests are green. No code changes are needed for this review 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.

Could this be implemented based on purity instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 11,875 tests pass and make phpstan reports no errors.

Regarding the reviewer's question — a purity-based approach is not feasible here for three reasons:

  1. Circular dependency: resolveType() would need to resolve the type of the receiver to look up method reflection and check isPure(), but we're in the middle of resolving the type. This creates a circular dependency.

  2. Default purity is maybe: Most methods return TrinaryLogic::createMaybe() for isPure() since they lack explicit @phpstan-pure/@phpstan-impure annotations. Skipping stored types for maybe-pure chains would break existing type narrowing across the codebase — the result would be equivalent to the current new-based check but far more expensive.

  3. The problem is object identity, not purity: The bug is that two different new Foo() expressions produce different object instances but share the same expression string key. Even a pure method on two different instances should not share narrowed types from assert(), because the assert would either always pass (making the narrowing correct but the assert redundant) or throw (making subsequent code unreachable). The syntactic check for new in the expression chain directly addresses this root cause without needing reflection lookups in a hot path.

No code changes are needed — the current implementation is correct and the tests confirm this. The response to the reviewer should explain these trade-offs.

@staabm staabm force-pushed the create-pull-request/patch-r89jstk branch from 40e9984 to 16ab672 Compare April 20, 2026 04:58
@VincentLanglet
Copy link
Copy Markdown
Contributor

I have multiple interrogation about this PR @staabm

1°) Why the expressionHasNewInChain is done in MutatingScope::resolveType ; shouldn't it be done when it try to save the expressionTypes (and should skip it instead) ? That would have the benefit to save (a little bit of) memory.

2°) Why the testBug8985 does not report any error ; I would expect a offset 0 might not exist error.

3°) Shouldn't this be fixed by marking all the constructor as impure instead ? Cause I feel like this dedicated check for newInChain is kinda wrong since it doesn't support named constructor. We'll still have the issue with

Repository::new()->getAll()

cf https://phpstan.org/r/188f2f42-e560-4a7d-b52e-e84ac3d3deb7

NB: In the last example, currently marking create as impure doesn't work
https://phpstan.org/r/61642e45-cee6-4032-933c-35899725bebc
while, marking getAll as impure works https://phpstan.org/r/f20edfa4-79cd-4faa-9be5-9147ebefa03b

So I feel like the issue is the fact the purity might be checked only for the last method call (?) WDYT

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 21, 2026

@VincentLanglet thanks for the input. I tried to push the bot into similar directions you suggested, but it didn't work yet :-)

1°) Why the expressionHasNewInChain is done in MutatingScope::resolveType ; shouldn't it be done when it try to save the expressionTypes (and should skip it instead) ? That would have the benefit to save (a little bit of) memory.

all previous attemps to not specify the types resulted in inline-phpdoc no longer beeing recognized. therefore the check went into type resolving time. I agree, that this doesn't feel right.

2°) Why the testBug8985 does not report any error ; I would expect a offset 0 might not exist error.

3°) Shouldn't this be fixed by marking all the constructor as impure instead ? Cause I feel like this dedicated check for newInChain is kinda wrong since it doesn't support named constructor. We'll still have the issue with

this points make me think, whether the initial snippet of the origin issue already works as expected before this PR.
PHPStan have all the information at hand to tell the error it does

  • involved objects are implicit-final, because they are constructed via new Repository() - so subclasses don't matter
  • we know that Repository does not have a constructor
  • involved methods don't have a :void return type, therefore are considered pure by default

So I feel like the issue is the fact the purity might be checked only for the last method call (?) WDYT

I think you are right. even adding a explicit impure constructor does not make the error go away
https://phpstan.org/r/081adb99-5211-4da8-b11d-9de47353cf78

so I think the origin snippet works as expected but the variants we figured out which have some explicit/maybe impurity don't work.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 21, 2026

I think the bug is here

if (
$expr instanceof MethodCall
&& $expr->name instanceof Node\Identifier
) {
$methodName = $expr->name->toString();
$calledOnType = $scope->getType($expr->var);
$methodReflection = $scope->getMethodReflection($calledOnType, $methodName);
if (
$methodReflection === null
|| $methodReflection->hasSideEffects()->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
) {
if (isset($containsNull) && !$containsNull) {
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
}
return new SpecifiedTypes([], []);
}
}
if (
$expr instanceof StaticCall
&& $expr->name instanceof Node\Identifier
) {
$methodName = $expr->name->toString();
if ($expr->class instanceof Name) {
$calledOnType = $scope->resolveTypeByName($expr->class);
} else {
$calledOnType = $scope->getType($expr->class);
}
$methodReflection = $scope->getMethodReflection($calledOnType, $methodName);
if (
$methodReflection === null
|| $methodReflection->hasSideEffects()->yes()
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
) {
if (isset($containsNull) && !$containsNull) {
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
}
return new SpecifiedTypes([], []);
}
}

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is not about having new in chain, cause the issue exists with any impure call in the method chains. Like

assert(Repository::create()->getAll() === []);

$all = Repository::create()->getAll();
$value = $all[0]->getValue();

with create being impure.

the issue might be related to TypeSpecifier::createForExpr which check on side effect of the methodReflection while the calledOnType might contains an impure call.

Same could also occur with an expression like

assert($repository->getAll(Repository::create()) === []);

$all = $repository->getAll(Repository::create());
$value = $all[0]->getValue();

with create being impure.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes addressing VincentLanglet's review:

Root cause fix in TypeSpecifier::createForExpr: Added two methods:

  • subExpressionsHaveSideEffects() - checks the receiver chain and arguments of the outermost expression for impure sub-expressions
  • expressionHasSideEffects() - recursively checks if an expression has side effects (handles New_, FuncCall, MethodCall, StaticCall, PropertyFetch, ArrayDimFetch, etc.)

The check runs after the existing purity checks in createForExpr (which only verify the outermost call's purity) and prevents narrowed types from being stored when:

  1. The receiver chain contains impure calls - e.g., Repository::create()->getAll() where create() is @phpstan-impure
  2. Any argument contains impure calls - e.g., $repo->getAllFor(Repository::create())

The existing expressionHasNewInChain check in MutatingScope::resolveType is kept as a safety net for the new case.

Tests added: Named constructor patterns (Repository::create()->getAll(), chained, property fetch) and impure argument patterns, both as NSRT tests and rule tests. All 11876 tests pass, make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this fix be merged with existing

if (
			$expr instanceof MethodCall
			&& $expr->name instanceof Node\Identifier
		) {

and

if (
			$expr instanceof StaticCall
			&& $expr->name instanceof Node\Identifier
		) {

conditions since they check on

$methodReflection->hasSideEffects()->yes()
				|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())

?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of the changes addressing VincentLanglet's review:

What changed: Merged the sub-expression side effect checks into the existing MethodCall, StaticCall, and FuncCall purity conditions in TypeSpecifier::createForExpr, rather than having them as a separate standalone check.

  • MethodCall block: Now checks || $this->expressionHasSideEffects($expr->var, $scope) || $this->callLikeArgsHaveSideEffects($expr, $scope) alongside the existing method purity check
  • StaticCall block: Now checks || ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) || $this->callLikeArgsHaveSideEffects($expr, $scope) alongside the existing method purity check
  • FuncCall blocks (both named and non-Name): Now check $this->callLikeArgsHaveSideEffects($expr, $scope) after the existing function purity checks
  • Extracted callLikeArgsHaveSideEffects() helper method, reused by both the merged conditions and subExpressionsHaveSideEffects()
  • subExpressionsHaveSideEffects() remains as a catch-all for expression types not covered by the blocks above (PropertyFetch, ArrayDimFetch, NullsafeMethodCall, NullsafePropertyFetch, StaticPropertyFetch, dynamic-name MethodCall/StaticCall)

All 11876 tests pass and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert changes in MutatingScope expressionHasNewInChain checks shouldn't be needed now we rely on sideEffects

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Removed the expressionHasNewInChain method and its usage in MutatingScope::resolveType. The TypeSpecifier's expressionHasSideEffects() already treats New_ as impure, so the narrowed types are never stored in the first place — making the read-time check in resolveType redundant. All 11876 tests pass and make phpstan reports no errors.

@VincentLanglet
Copy link
Copy Markdown
Contributor

I feel like the fix is now correct (but complicate) @staabm. We might need the opinion from ondrej

staabm and others added 8 commits April 23, 2026 07:02
- Added expressionHasNewInChain() check in MutatingScope::resolveType()
  to skip stored expression type lookup when the expression's receiver
  chain contains a New_ node
- New regression test in tests/PHPStan/Analyser/nsrt/bug-8985.php
- The root cause was that (new Foo())->method() produced the same
  expression key regardless of source location, so type narrowing from
  assert() on one new instance incorrectly applied to subsequent ones
…t resolve time

Move the expressionHasNewInChain check from resolveType() to
specifyExpressionType() so that expressions based on new are never
remembered in the first place, rather than being stored and then
ignored at lookup time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pression types

The recursive call in expressionHasNewInChain() could receive a Name node
(e.g. from Foo::bar()) instead of an Expr, causing a type error. Added an
instanceof Expr guard before recursing on $expr->class.

Also added test coverage for all expression types handled by
expressionHasNewInChain(): nullsafe method calls, property fetches,
nullsafe property fetches, array dim fetches, static calls, and chained
expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ession types

Class constants are class-level, not instance-level, so narrowed types
from (new Foo())::MY_CONST should still be remembered. Also fix a type
error where $expr->class could be a Name (not Expr) for static calls.

Added tests covering: method calls, nullsafe method calls, property
fetches, nullsafe property fetches, array dim fetches, static calls,
static property fetches, class const fetches, and chained expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
staabm and others added 9 commits April 23, 2026 07:02
…he same AST node

When an expression has `new` in its receiver chain, only skip the stored
type lookup if the stored type came from a different AST node (e.g. assert
narrowing). If the stored type was set for the same AST node (e.g. inline
@var annotation), allow it through. This fixes @var overrides on
expressions like `return (new Defaults())->getFunctions()`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of only checking for `new` in the expression chain at type
resolution time, prevent TypeSpecifier from storing narrowed types
when the receiver chain or arguments contain impure calls.

This generalizes the fix to handle named constructor patterns like
`Repository::create()->getAll()` where `create()` is impure, and
impure arguments like `$repo->getAll(Repository::create())`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover:
- Named constructor: Repository::create()->getAll() with impure create()
- Chained named constructor: Repository::create()->getEntity()->getValue()
- Named constructor property: Repository::create()->name
- Impure argument: $repository->getAllFor(Repository::create())

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of having a standalone subExpressionsHaveSideEffects check
separate from the existing MethodCall/StaticCall/FuncCall purity
conditions, merge the receiver chain and argument side effect checks
directly into those conditions. This addresses the review feedback
that the hasSideEffects() checking pattern was duplicated.

- MethodCall block: now also checks receiver ($expr->var) and args
- StaticCall block: now also checks class expr and args
- FuncCall blocks: now also check args for side effects
- Extract callLikeArgsHaveSideEffects() helper, reused by both
  the existing blocks and subExpressionsHaveSideEffects()
- subExpressionsHaveSideEffects() remains as catch-all for types
  not covered by the blocks above (PropertyFetch, ArrayDimFetch,
  NullsafeMethodCall, NullsafePropertyFetch, StaticPropertyFetch)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The TypeSpecifier side-effect checks now handle the new-in-chain case:
expressionHasSideEffects() treats New_ as impure, preventing narrowed
types from being stored for any expression with new in its chain.
The MutatingScope check is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-r89jstk branch from 7a3c0e9 to 9fce389 Compare April 23, 2026 05:02
Comment thread src/Analyser/TypeSpecifier.php Outdated
Move expression side-effect detection logic from TypeSpecifier into a
dedicated ExprSideEffectsHelper class with public rememberFuncCall,
rememberMethodCall, rememberStaticCall, and subExpressionsHaveSideEffects
methods that encapsulate all purity and side-effect conditions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm requested a review from VincentLanglet April 23, 2026 05:26
Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for pushing it further.

I tried a similar direction with my first prompts on this PR, but it seems you have a better connection to the bot than me :-)

@staabm staabm changed the title Fix phpstan/phpstan#8985: Expression result remembered on new() Fix phpstan/phpstan#8985: Expression result remembered impure call-chains Apr 23, 2026
@staabm staabm changed the title Fix phpstan/phpstan#8985: Expression result remembered impure call-chains Fix phpstan/phpstan#8985: Expression result remembered on impure call-chains Apr 23, 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.

3 participants