From 4fb8629df2967c1b881aa2d8197ea7354ce57827 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Jul 2026 15:54:24 +0200 Subject: [PATCH] Derive ArrayAccess and __set simulation throw points directly instead of walking synthetic calls The offsetGet/offsetSet/offsetExists/offsetUnset and __set simulations built a synthetic MethodCall and processed it through processExprNode() with a NoopNodeCallback just to read the resulting throw points. The new MethodThrowPointHelper::getThrowPointsForCallOnType() derives them directly from the receiver type's method reflection; the fabricated node is only the throw-point anchor and the payload dynamic throw-type extensions receive - nothing processes it. The __set simulation no longer re-walks the real receiver, whose throw points the main walk already collected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- .../ExprHandler/ArrayDimFetchHandler.php | 17 +++++----- src/Analyser/ExprHandler/AssignHandler.php | 25 +++++++-------- .../Helper/MethodThrowPointHelper.php | 31 +++++++++++++++++++ src/Analyser/ExprHandler/IssetHandler.php | 13 ++++---- src/Analyser/NodeScopeResolver.php | 11 +++---- 5 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 9e0bb3021f6..dfdc46932d3 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -14,11 +14,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -37,7 +37,10 @@ final class ArrayDimFetchHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private MethodThrowPointHelper $methodThrowPointHelper, + ) { } @@ -123,14 +126,12 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, $varType = $varResult->getType(); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall(new TypeExpr($varType), 'offsetGet'), + $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, - $storage, - new NoopNodeCallback(), $context, - )->getThrowPoints()); + $varType, + new MethodCall(new TypeExpr($varType), 'offsetGet'), + )); } return $this->expressionResultFactory->create( diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index d65abb00565..ba29dbf3258 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -29,6 +29,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -105,6 +106,7 @@ public function __construct( private ArrayDimFetchHandler $arrayDimFetchHandler, private PropertyFetchHandler $propertyFetchHandler, private StaticPropertyFetchHandler $staticPropertyFetchHandler, + private MethodThrowPointHelper $methodThrowPointHelper, ) { } @@ -1080,14 +1082,12 @@ public function applyWrite( && !$setVarType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($setVarType)->no() ) { - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall(new TypeExpr($setVarType), 'offsetSet'), + $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, - $storage, - new NoopNodeCallback(), $context, - )->getThrowPoints()); + $setVarType, + new MethodCall(new TypeExpr($setVarType), 'offsetSet'), + )); } } elseif ($kind === PreparedAssignTarget::KIND_PROPERTY_FETCH) { if (!$var instanceof PropertyFetch) { @@ -1173,16 +1173,15 @@ public function applyWrite( $assignedExprType = $scope->getType($assignedExpr); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); - // simulate dynamic property assign by __set to get throw points + // simulate dynamic property assign by __set to get throw points; + // the receiver's own throw points were already collected by its walk if (!$propertyHolderType->hasMethod('__set')->no()) { - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall($var->var, '__set'), + $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, - $storage, - new NoopNodeCallback(), $context, - )->getThrowPoints()); + $propertyHolderType, + new MethodCall($var->var, '__set'), + )); } } diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 2658beb2ce0..5edef897727 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -4,6 +4,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Identifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -13,10 +14,13 @@ use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\DynamicMethodThrowTypeExtension; use PHPStan\Type\DynamicStaticMethodThrowTypeExtension; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -107,4 +111,31 @@ public function getThrowPoint( return null; } + /** + * The throw points of invoking a method on an already-priced receiver + * type - what walking a synthetic MethodCall used to produce, without the + * walk. The method call node is only the throw-point anchor and the + * payload dynamic throw-type extensions receive; nothing processes it. + * + * @return list + */ + public function getThrowPointsForCallOnType(MutatingScope $scope, ExpressionContext $context, Type $calledOnType, MethodCall $methodCall): array + { + if (!$methodCall->name instanceof Identifier) { + throw new ShouldNotHappenException(); + } + + $methodReflection = $scope->getMethodReflection($calledOnType, $methodCall->name->toString()); + if ($methodReflection === null) { + return [InternalThrowPoint::createImplicit($scope, $methodCall)]; + } + + $throwPoint = $this->getThrowPoint($methodReflection, ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants()), $methodCall, $scope, $context); + if ($throwPoint === null) { + return []; + } + + return [$throwPoint]; + } + } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 62d9abadf04..7f0d6eb5e3b 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -18,10 +18,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -62,6 +62,7 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private MethodThrowPointHelper $methodThrowPointHelper, ) { } @@ -375,14 +376,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall(new TypeExpr($varType), 'offsetExists'), + $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, - $storage, - new NoopNodeCallback(), $context, - )->getThrowPoints()); + $varType, + new MethodCall(new TypeExpr($varType), 'offsetExists'), + )); } foreach (array_reverse($expr->vars) as $var) { $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d0ad5114571..0ebe176db1f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -52,6 +52,7 @@ use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; +use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -2397,14 +2398,12 @@ public function processStmtNode( if ($var instanceof ArrayDimFetch && $var->dim !== null) { $varType = $scope->getType($var->var); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { - $throwPoints = array_merge($throwPoints, $this->processExprNode( - $stmt, - new MethodCall(new TypeExpr($varType), 'offsetUnset'), + $throwPoints = array_merge($throwPoints, $this->container->getByType(MethodThrowPointHelper::class)->getThrowPointsForCallOnType( $scope, - $storage, - new NoopNodeCallback(), ExpressionContext::createDeep(), - )->getThrowPoints()); + $varType, + new MethodCall(new TypeExpr($varType), 'offsetUnset'), + )); } $clonedVar = $this->deepNodeCloner->cloneNode($var->var);