Fix phpstan/phpstan#8985: Expression result remembered on impure call-chains#5449
Fix phpstan/phpstan#8985: Expression result remembered on impure call-chains#5449phpstan-bot wants to merge 19 commits intophpstan:2.1.xfrom
Conversation
9b7fe1f to
e7850f1
Compare
|
add tests for https://phpstan.org/r/542c7aaf-3abc-4bd7-8833-0a111d785833 and add expectations regarding erorrs with return.type identifier |
e7850f1 to
f24ac4b
Compare
staabm
left a comment
There was a problem hiding this comment.
fix the implementation so newly added tests, which currently fail get green.
don't change the tests
|
Pushed. The fix uses AST node identity to distinguish between:
All 11875 tests pass and |
| && !$node instanceof Expr\Closure | ||
| && !$node instanceof Expr\ArrowFunction | ||
| && $this->hasExpressionType($node)->yes() | ||
| && (!$this->expressionHasNewInChain($node) || $this->expressionTypes[$exprString]->getExpr() === $node) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.,
@varannotation set on this exact expression) — stored type is used - Different AST node with same string key (e.g.,
assert()narrowing from a differentnewinstantiation) — stored type is skipped
The implementation is already correct and all tests are green. No code changes are needed for this review comment.
There was a problem hiding this comment.
Could this be implemented based on purity instead?
There was a problem hiding this comment.
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:
-
Circular dependency:
resolveType()would need to resolve the type of the receiver to look up method reflection and checkisPure(), but we're in the middle of resolving the type. This creates a circular dependency. -
Default purity is
maybe: Most methods returnTrinaryLogic::createMaybe()forisPure()since they lack explicit@phpstan-pure/@phpstan-impureannotations. Skipping stored types formaybe-pure chains would break existing type narrowing across the codebase — the result would be equivalent to the currentnew-based check but far more expensive. -
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 fromassert(), because the assert would either always pass (making the narrowing correct but the assert redundant) or throw (making subsequent code unreachable). The syntactic check fornewin 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.
40e9984 to
16ab672
Compare
|
I have multiple interrogation about this PR @staabm 1°) Why the 2°) Why the 3°) Shouldn't this be fixed by marking all the constructor as impure instead ? Cause I feel like this dedicated check for cf https://phpstan.org/r/188f2f42-e560-4a7d-b52e-e84ac3d3deb7 NB: In the last example, currently marking So I feel like the issue is the fact the purity might be checked only for the last method call (?) WDYT |
|
@VincentLanglet thanks for the input. I tried to push the bot into similar directions you suggested, but it didn't work yet :-)
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.
this points make me think, whether the initial snippet of the origin issue already works as expected before this PR.
I think you are right. even adding a explicit impure constructor does not make the error go away so I think the origin snippet works as expected but the variants we figured out which have some explicit/maybe impurity don't work. |
|
I think the bug is here phpstan-src/src/Analyser/TypeSpecifier.php Lines 2363 to 2406 in 5ab83ce |
VincentLanglet
left a comment
There was a problem hiding this comment.
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.
|
Pushed. Here's a summary of the changes addressing VincentLanglet's review: Root cause fix in
The check runs after the existing purity checks in
The existing Tests added: Named constructor patterns ( |
VincentLanglet
left a comment
There was a problem hiding this comment.
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())
?
|
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
All 11876 tests pass and |
VincentLanglet
left a comment
There was a problem hiding this comment.
Revert changes in MutatingScope expressionHasNewInChain checks shouldn't be needed now we rely on sideEffects
|
Removed the |
|
I feel like the fix is now correct (but complicate) @staabm. We might need the opinion from ondrej |
- 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>
…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>
7a3c0e9 to
9fce389
Compare
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
left a comment
There was a problem hiding this comment.
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 :-)
Fixes phpstan/phpstan#8985