From 02b81f1a441aa20e3fdd6b390515030d60c76512 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 21:54:52 +0200 Subject: [PATCH 1/5] Carry the closure body walk's gathered statements on ProcessClosureResult processClosureNode() already sees every return, yield and execution end of the closure body on the scope they occur on. Record the statement-scope pairs and carry them - together with the impure points pre-merged in getClosureType() order - on ProcessClosureResult, so a closure type can later be built from the single walk instead of walking the body again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/NodeScopeResolver.php | 30 +++++++++++++++-- src/Analyser/ProcessClosureResult.php | 48 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index aefe7850be2..10db3ea5774 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3038,10 +3038,12 @@ public function processClosureNode( $executionEnds = []; $gatheredReturnStatements = []; + $gatheredReturnStatementsWithScope = []; $gatheredYieldStatements = []; + $gatheredYieldStatementsWithScope = []; $closureImpurePoints = []; $invalidateExpressions = []; - $closureStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $scope) use (&$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { + $closureStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $scope) use (&$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { return; } @@ -3066,12 +3068,14 @@ public function processClosureNode( } if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { $gatheredYieldStatements[] = $node; + $gatheredYieldStatementsWithScope[] = [$node, $scope]; } if (!$node instanceof Return_) { return; } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $gatheredReturnStatementsWithScope[] = [$node, $scope]; }, $nodeCallback); if (count($byRefUses) === 0) { @@ -3086,7 +3090,16 @@ public function processClosureNode( array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureScope, $storage); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions); + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + ); } $originalStorage = $storage; @@ -3136,7 +3149,18 @@ public function processClosureNode( array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), ), $closureScope, $storage); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $closureResultScope, $byRefUses); + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + $closureResultScope, + $byRefUses, + ); } /** diff --git a/src/Analyser/ProcessClosureResult.php b/src/Analyser/ProcessClosureResult.php index cb4b609e1f4..e114ac55b81 100644 --- a/src/Analyser/ProcessClosureResult.php +++ b/src/Analyser/ProcessClosureResult.php @@ -3,6 +3,10 @@ namespace PHPStan\Analyser; use PhpParser\Node\ClosureUse; +use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Expr\YieldFrom; +use PhpParser\Node\Stmt\Return_; +use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\InvalidateExprNode; final class ProcessClosureResult @@ -12,6 +16,10 @@ final class ProcessClosureResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param InvalidateExprNode[] $invalidateExpressions + * @param list $gatheredReturnStatements + * @param list $gatheredYieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $closureTypeImpurePoints already merged in getClosureType() order (property-assign impure points first, then statement result impure points) * @param ClosureUse[] $byRefUses */ public function __construct( @@ -19,6 +27,10 @@ public function __construct( private array $throwPoints, private array $impurePoints, private array $invalidateExpressions, + private array $gatheredReturnStatements, + private array $gatheredYieldStatements, + private array $executionEnds, + private array $closureTypeImpurePoints, private ?MutatingScope $byRefClosureResultScope = null, private array $byRefUses = [], ) @@ -63,4 +75,40 @@ public function getInvalidateExpressions(): array return $this->invalidateExpressions; } + /** + * @return list + */ + public function getGatheredReturnStatements(): array + { + return $this->gatheredReturnStatements; + } + + /** + * @return list + */ + public function getGatheredYieldStatements(): array + { + return $this->gatheredYieldStatements; + } + + /** + * @return list + */ + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + /** + * The closure body's impure points already merged in getClosureType() order + * (property-assign impure points first, then statement result impure points), + * ready to feed ClosureTypeResolver::buildClosureTypeForClosure(). + * + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + } From 3ea94b07f35676a665436d2503a276543d0147f2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 22:05:34 +0200 Subject: [PATCH 2/5] Restructure ClosureTypeResolver around single-walk closure type builders buildClosureTypeForClosure()/buildClosureTypeForArrowFunction() construct a ClosureType from the return/yield statements and execution ends a prior body walk already gathered (reading their types off the gathered scopes, natively via the promoted flavour when asked), without walking the body again. getClosureType() - the walking resolver - now shares their whole tail: parameter building (defaults priced through InitializerExprTypeResolver), the never/void execution-end derivation, the Generator return shape, and the final assembly with its cache write. array_map() callbacks and immediately invoked closures keep re-walking (their parameters are typed from the call site, so a foreign walk's gathered scopes would answer wrongly). No caller feeds the builders yet; cache keys and cache participation are unchanged (arrows still price fresh on every ask). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- .../Helper/ClosureTypeResolver.php | 860 ++++++++++++------ 1 file changed, 584 insertions(+), 276 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ff165386f75..aaea7289211 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -25,7 +26,10 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; @@ -38,12 +42,15 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\VerbosityLevel; use PHPStan\Type\VoidType; use function array_key_exists; use function array_map; use function array_merge; use function count; +use function implode; use function is_string; #[AutowiredService] @@ -54,137 +61,115 @@ final class ClosureTypeResolver public function __construct( private NodeScopeResolver $nodeScopeResolver, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { } + /** + * Resolves a closure/arrow function type by walking its body itself. Used by + * the paths that have no prior walk to read return/yield types from. A + * self-by-ref closure legitimately re-walks here (the + * $resolveClosureTypeDepth guard answers that ask). + * + * Callers that have already walked the body feed the gathered + * returns/yields to buildClosureTypeForClosure()/ + * buildClosureTypeForArrowFunction() instead, which construct the same + * ClosureType without a second walk. + */ public function getClosureType( MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ): ClosureType { - $parameters = []; - $isVariadic = false; - $firstOptionalParameterIndex = null; - foreach ($expr->params as $i => $param) { - $isOptionalCandidate = $param->default !== null || $param->variadic; + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); - if ($isOptionalCandidate) { - if ($firstOptionalParameterIndex === null) { - $firstOptionalParameterIndex = $i; - } - } else { - $firstOptionalParameterIndex = null; - } + $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cacheKey = $scope->getClosureScopeCacheKey(); + if (!$expr instanceof ArrowFunction && array_key_exists($cacheKey, $cachedTypes)) { + return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); } - - foreach ($expr->params as $i => $param) { - if ($param->variadic) { - $isVariadic = true; - } - if (!$param->var instanceof Variable || !is_string($param->var->name)) { - throw new ShouldNotHappenException(); - } - $parameters[] = new NativeParameterReflection( - $param->var->name, - $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, - $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), - $param->byRef - ? PassedByReference::createCreatesNewVariable() - : PassedByReference::createNo(), - $param->variadic, - $param->default !== null ? $scope->getType($param->default) : null, + if (self::$resolveClosureTypeDepth >= 2) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), ); } - $callableParameters = null; - $nativeCallableParameters = null; - $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); - $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); - if ($arrayMapArgs !== null) { - $callableParameters = []; - $nativeCallableParameters = []; - foreach ($arrayMapArgs as $funcCallArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } elseif ($immediatelyInvokedArgs !== null) { - foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } else { - $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { - [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; - if ($inParameter !== null) { - $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); - $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); - $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); - } - } - } - if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { - $yieldNode = $expr->expr; + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + self::$resolveClosureTypeDepth++; + try { + $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( + new Node\Stmt\Expression($expr->expr), + $expr->expr, + $arrowScope, + new ExpressionResultStorage(), + static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $arrowScope->getAnonymousFunctionReflection()) { + return; + } - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyType = new IntegerType(); - } else { - $keyType = $arrowScope->getType($yieldNode->key); - } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - if ($yieldNode->value === null) { - $valueType = new NullType(); - } else { - $valueType = $arrowScope->getType($yieldNode->value); - } - } else { - $yieldFromType = $arrowScope->getType($yieldNode->expr); - $keyType = $arrowScope->getIterableKeyType($yieldFromType); - $valueType = $arrowScope->getIterableValueType($yieldFromType); - } + if (!$node instanceof PropertyAssignNode) { + return; + } - $returnType = new GenericObjectType(Generator::class, [ - $keyType, - $valueType, - new MixedType(), - new VoidType(), - ]); - } else { - $returnType = $arrowScope->getKeepVoidType($expr->expr); - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); - } + $arrowFunctionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + }, + ExpressionContext::createDeep(), + ); + } finally { + self::$resolveClosureTypeDepth--; } + $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); + $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $arrowFunctionImpurePoints = []; - $invalidateExpressions = []; - $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( - new Node\Stmt\Expression($expr->expr), - $expr->expr, - $arrowScope, - new ExpressionResultStorage(), - static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $arrowScope->getAnonymousFunctionReflection()) { - return; - } + // the body was processed just above; resolve the return type from its stored + // result rather than reading the still-unprocessed body expression + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey); + } - if (!$node instanceof PropertyAssignNode) { - return; - } + self::$resolveClosureTypeDepth++; + + $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); + $closureReturnStatements = []; + $closureYieldStatements = []; + $closureExecutionEnds = []; + $closureImpurePoints = []; + $invalidateExpressions = []; + + try { + $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$closureExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - $arrowFunctionImpurePoints[] = new ImpurePoint( + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( $scope, $node, 'propertyAssign', @@ -192,219 +177,541 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu true, ); $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - }, - ExpressionContext::createDeep(), - ); - $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); - $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $usedVariables = []; - } else { - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey = $scope->getClosureScopeCacheKey(); - if (array_key_exists($cacheKey, $cachedTypes)) { - $cachedClosureData = $cachedTypes[$cacheKey]; - - $mustUseReturnValue = TrinaryLogic::createNo(); - foreach ($expr->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toLowerString() === 'nodiscard') { - $mustUseReturnValue = TrinaryLogic::createYes(); - break; - } - } + return; } - return new ClosureType( - $parameters, - $cachedClosureData['returnType'], - $isVariadic, - TemplateTypeMap::createEmpty(), - TemplateTypeMap::createEmpty(), - TemplateTypeVarianceMap::createEmpty(), - throwPoints: $cachedClosureData['throwPoints'], - impurePoints: $cachedClosureData['impurePoints'], - invalidateExpressions: $cachedClosureData['invalidateExpressions'], - usedVariables: $cachedClosureData['usedVariables'], - acceptsNamedArguments: TrinaryLogic::createYes(), - mustUseReturnValue: $mustUseReturnValue, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); - } - if (self::$resolveClosureTypeDepth >= 2) { - return new ClosureType( - $parameters, - $scope->getFunctionType($expr->returnType, false, false), - $isVariadic, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); - } + if ($node instanceof ExecutionEndNode) { + $closureExecutionEnds[] = $node; + return; + } - self::$resolveClosureTypeDepth++; + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } - $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $closureReturnStatements = []; - $closureYieldStatements = []; - $onlyNeverExecutionEnds = null; - $closureImpurePoints = []; - $invalidateExpressions = []; + if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { + return; + } - try { - $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $closureReturnStatements, + $closureYieldStatements, + $closureExecutionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + $cacheKey, + ); + } - if ($node instanceof ExecutionEndNode) { - if ($node->getStatementResult()->isAlwaysTerminating()) { - foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { - if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { - $onlyNeverExecutionEnds = false; - continue; - } - - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - - break; - } - - if (count($node->getStatementResult()->getExitPoints()) === 0) { - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - } - } else { - $onlyNeverExecutionEnds = false; - } + /** + * Constructs a closure type from data the engine already gathered while + * walking the body once (see NodeScopeResolver::processClosureNode()), + * without a second walk. The return/yield expression types are read from + * their stored results. + * + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param InternalThrowPoint[] $throwPoints the single body walk's internal throw points + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + statement result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForClosure( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + bool $native = false, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); + } - return; - } + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); - if ($node instanceof Node\Stmt\Return_) { - $closureReturnStatements[] = [$node, $scope]; - } + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $returnStatements, + $yieldStatements, + $executionEnds, + array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $throwPoints), + $impurePoints, + $invalidateExpressions, + // the flavour-correct key both keeps the native build from clobbering + // the phpdoc cache slot and lets a later getClosureType() ask on the + // promoted scope answer from this build instead of re-walking + $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + ), + $native, + ); + } - if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { - return; - } + /** + * Constructs an arrow function type from data the engine already gathered + * while walking the body once (see NodeScopeResolver:: + * processArrowFunctionNode()), without a second walk. The return/yield + * expression types are read from their stored results on $arrowScope. + * + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + expression result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForArrowFunction( + MutatingScope $scope, + ArrowFunction $expr, + MutatingScope $arrowScope, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + bool $native = false, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); + } - $closureYieldStatements[] = [$node, $scope]; - }, StatementContext::createTopLevel()); - } finally { - self::$resolveClosureTypeDepth--; + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); + + // the flavour-correct key both keeps the native build from clobbering the + // phpdoc cache slot and lets a later getClosureType() ask on the promoted + // scope answer from this build instead of re-walking + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + )); + } + + /** + * The cache key of everything this closure's type can depend on: the + * enclosing scope plus the parameter types the caller feeds in - + * array_map style callers type the same closure node per element + * through the callable parameters. + * + * @param array|null $callableParameters + * @param array $parameters + */ + private function closureContextCacheKey(MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ?array $callableParameters, array $parameters): string + { + $parts = []; + foreach ($callableParameters ?? $parameters as $parameter) { + $parts[] = $parameter->getType()->describe(VerbosityLevel::cache()); + } + + // only arrow functions get a flavour-separated slot: their native return + // type genuinely differs from the phpdoc one. A closure's two flavours are + // distinguished by the scope hash alone - and deliberately share values + // when the seeding pin copies the phpdoc build to the promoted key + $flavour = $expr instanceof ArrowFunction ? ($scope->nativeTypesPromoted ? '/native' : '/phpdoc') : ''; + + return $scope->getClosureScopeCacheKey() . '/' . implode('|', $parts) . $flavour; + } + + /** + * Whether getClosureType() would walk the body with different parameter types + * than NodeScopeResolver's single walk (processClosureNode()/ + * processArrowFunctionNode()) did. array_map() callbacks and immediately + * invoked closures get their parameter types from the array element type / + * the invocation arguments in getClosureType(), whereas the single walk types + * them from the closure's passed-to callable type - so the return type read + * from the gathered scopes would differ, and getClosureType() must re-walk. + */ + private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool + { + return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null + || $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME) !== null; + } + + /** + * @param list $parameters + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + */ + private function buildClosureTypeFromClosureWalk( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $parameters, + bool $isVariadic, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ?string $cacheKey = null, + bool $native = false, + ): ClosureType + { + $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); + + // like resolveArrowFunctionReturnType(): the single walk stored both + // flavours on the gathered scopes, so the native flavour just reads the + // stored native types - no second body walk on the promoted scope + $returnTypes = []; + $hasNull = false; + foreach ($returnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } + + $readScope = $returnScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } + $returnTypes[] = $readScope->getType($returnNode->expr); + } + + if (count($returnTypes) === 0) { + if ($onlyNeverExecutionEnds === true && !$hasNull) { + $returnType = new NonAcceptingNeverType(); + } else { + $returnType = new VoidType(); + } + } else { + if ($onlyNeverExecutionEnds === true) { + $returnTypes[] = new NonAcceptingNeverType(); + } + if ($hasNull) { + $returnTypes[] = new NullType(); } + $returnType = TypeCombinator::union(...$returnTypes); + } + + if (count($yieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($yieldStatements as [$yieldNode, $yieldScope]) { + $readScope = $yieldScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $readScope->getType($yieldNode->key); + } - $throwPoints = $closureStatementResult->getThrowPoints(); - $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $readScope->getType($yieldNode->value); + } - $returnTypes = []; - $hasNull = false; - foreach ($closureReturnStatements as [$returnNode, $returnScope]) { - if ($returnNode->expr === null) { - $hasNull = true; continue; } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyTypes[] = $readScope->getIterableKeyType($yieldFromType); + $valueTypes[] = $readScope->getIterableValueType($yieldFromType); + } + + $returnType = new GenericObjectType(Generator::class, [ + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes), + new MixedType(), + $returnType, + ]); + } else { + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } + } + + $usedVariables = []; + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $usedVariables[] = $use->var->name; + } + + foreach ($expr->uses as $use) { + if (!$use->byRef) { + continue; } - if (count($returnTypes) === 0) { - if ($onlyNeverExecutionEnds === true && !$hasNull) { - $returnType = new NonAcceptingNeverType(); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'functionCall', + 'call to a Closure with by-ref use', + true, + ); + break; + } + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables, $cacheKey); + } + + private function resolveArrowFunctionReturnType( + MutatingScope $scope, + MutatingScope $arrowScope, + ArrowFunction $expr, + bool $native = false, + ): Type + { + // Unlike a closure (whose native type equals its phpdoc type), an arrow + // function's native return type is the body expression's native type + // (e.g. fn () => methodReturningPositiveInt() is Closure(): int natively, + // Closure(): int<1, max> in phpdoc). The body was already processed in the + // single walk, so the native flavour just reads the stored native types off + // the same arrowScope - no second walk. + $readScope = $native ? $arrowScope->doNotTreatPhpDocTypesAsCertain() : $arrowScope; + + if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { + $yieldNode = $expr->expr; + + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyType = new IntegerType(); } else { - $returnType = new VoidType(); - } - } else { - if ($onlyNeverExecutionEnds === true) { - $returnTypes[] = new NonAcceptingNeverType(); + $keyType = $readScope->getType($yieldNode->key); } - if ($hasNull) { - $returnTypes[] = new NullType(); + + if ($yieldNode->value === null) { + $valueType = new NullType(); + } else { + $valueType = $readScope->getType($yieldNode->value); } - $returnType = TypeCombinator::union(...$returnTypes); + } else { + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyType = $readScope->getIterableKeyType($yieldFromType); + $valueType = $readScope->getIterableValueType($yieldFromType); } - if (count($closureYieldStatements) > 0) { - $keyTypes = []; - $valueTypes = []; - foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyTypes[] = new IntegerType(); - } else { - $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); - } + return new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); + } - if ($yieldNode->value === null) { - $valueTypes[] = new NullType(); - } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); - } + $returnType = $readScope->getKeepVoidType($expr->expr); + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } + + return $returnType; + } + /** + * Whether every execution end of the closure body is a "never" terminator + * (throw/exit) rather than a return: null when there were no execution ends, + * false once a return (or non-terminating end) is seen. + * + * @param list $executionEnds + */ + private function deriveOnlyNeverExecutionEnds(array $executionEnds): ?bool + { + $onlyNeverExecutionEnds = null; + foreach ($executionEnds as $node) { + if ($node->getStatementResult()->isAlwaysTerminating()) { + foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { + if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { + $onlyNeverExecutionEnds = false; continue; } - $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); - $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); - $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } + + break; } - $returnType = new GenericObjectType(Generator::class, [ - TypeCombinator::union(...$keyTypes), - TypeCombinator::union(...$valueTypes), - new MixedType(), - $returnType, - ]); - } else { - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + if (count($node->getStatementResult()->getExitPoints()) === 0) { + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } } + } else { + $onlyNeverExecutionEnds = false; } + } - $usedVariables = []; - foreach ($expr->uses as $use) { - if (!is_string($use->var->name)) { - continue; + return $onlyNeverExecutionEnds; + } + + /** + * Builds the closure/arrow function's declared parameters (independent of the + * body walk) and the callable parameter acceptors derived from the call site. + * + * @return array{list, bool, ParameterReflection[]|null, ParameterReflection[]|null} + */ + private function buildParametersAndAcceptors( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + ): array + { + $parameters = []; + $isVariadic = false; + $firstOptionalParameterIndex = null; + foreach ($expr->params as $i => $param) { + $isOptionalCandidate = $param->default !== null || $param->variadic; + + if ($isOptionalCandidate) { + if ($firstOptionalParameterIndex === null) { + $firstOptionalParameterIndex = $i; } + } else { + $firstOptionalParameterIndex = null; + } + } - $usedVariables[] = $use->var->name; + foreach ($expr->params as $i => $param) { + if ($param->variadic) { + $isVariadic = true; } + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $parameters[] = new NativeParameterReflection( + $param->var->name, + $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, + $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), + $param->byRef + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $param->variadic, + // a default is a constant expression - price it without a scope + // walk, the same way parameter defaults are priced elsewhere + $param->default !== null ? $this->initializerExprTypeResolver->getType($param->default, InitializerExprContext::fromScope($scope)) : null, + ); + } - foreach ($expr->uses as $use) { - if (!$use->byRef) { - continue; + $callableParameters = null; + $nativeCallableParameters = null; + $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + if ($arrayMapArgs !== null) { + $callableParameters = []; + $nativeCallableParameters = []; + foreach ($arrayMapArgs as $funcCallArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } elseif ($immediatelyInvokedArgs !== null) { + foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } else { + $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); + $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); + $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); } + } + } - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'functionCall', - 'call to a Closure with by-ref use', - true, - ); - break; + return [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters]; + } + + /** + * @param list $parameters + * @param array{returnType: Type, throwPoints: SimpleThrowPoint[], impurePoints: SimpleImpurePoint[], invalidateExpressions: InvalidateExprNode[], usedVariables: string[]} $cachedClosureData + */ + private function createClosureTypeFromCache( + Node\Expr\Closure|ArrowFunction $expr, + array $parameters, + bool $isVariadic, + array $cachedClosureData, + ): ClosureType + { + $mustUseReturnValue = TrinaryLogic::createNo(); + foreach ($expr->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toLowerString() === 'nodiscard') { + $mustUseReturnValue = TrinaryLogic::createYes(); + break; + } } } + return new ClosureType( + $parameters, + $cachedClosureData['returnType'], + $isVariadic, + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + throwPoints: $cachedClosureData['throwPoints'], + impurePoints: $cachedClosureData['impurePoints'], + invalidateExpressions: $cachedClosureData['invalidateExpressions'], + usedVariables: $cachedClosureData['usedVariables'], + acceptsNamedArguments: TrinaryLogic::createYes(), + mustUseReturnValue: $mustUseReturnValue, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + + /** + * Constructs the final ClosureType from the resolved return type and the + * gathered throw/impure/invalidate points. Adds the by-ref-parameter impure + * point, populates the per-scope phpdoc-type cache (closures only), and + * resolves the #[NoDiscard] attribute. + * + * @param list $parameters + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + * @param string[] $usedVariables + */ + private function assembleClosureType( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + array $parameters, + bool $isVariadic, + Type $returnType, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + array $usedVariables, + ?string $cacheKey = null, + ): ClosureType + { foreach ($parameters as $parameter) { if ($parameter->passedByReference()->no()) { continue; @@ -423,7 +730,8 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cachedTypes[$scope->getClosureScopeCacheKey()] = [ + $cacheKey ??= $scope->getClosureScopeCacheKey(); + $cachedTypes[$cacheKey] = [ 'returnType' => $returnType, 'throwPoints' => $throwPointsForClosureType, 'impurePoints' => $impurePointsForClosureType, From 16d4a1ab5d6232dda7f2f88353e32633d57d243c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 22:11:09 +0200 Subject: [PATCH 3/5] Seed the closure type cache from the handler's single body walk ClosureHandler::processExpr() walks the closure body for the rules; the lazy getClosureType() pricing then walked it again. The handler now seeds the per-node type cache from the gathered walk data, so the pricing asks answer without a second walk. The cache key becomes flavour- and parameter-aware (closureContextCacheKey), and the native-flavour slot is seeded with the same build: a closure's native type equals its phpdoc type, which until now only held when the promoted scope's holder hash happened to collide with the plain one - the pin is now deliberate and collision-independent. array_map() callbacks and immediately invoked closures seed through a getClosureType() walk with their call-site parameter types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/ExprHandler/ClosureHandler.php | 1 + .../Helper/ClosureTypeResolver.php | 45 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index b107c5843c6..07f600f69d4 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -42,6 +42,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); + $this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult); return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index aaea7289211..c1df4c77096 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\ProcessClosureResult; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\ThrowPoint; @@ -85,7 +86,7 @@ public function getClosureType( [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey = $scope->getClosureScopeCacheKey(); + $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); if (!$expr instanceof ArrowFunction && array_key_exists($cacheKey, $cachedTypes)) { return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); } @@ -311,6 +312,46 @@ public function buildClosureTypeForArrowFunction( )); } + /** + * Seeds the per-node closure type cache from the single body walk the + * engine just performed (see ClosureHandler::processExpr()), so later + * getClosureType() asks answer from the walk instead of walking the + * body again. + * + * A closure's native type equals its phpdoc type - the native-flavour + * slot is seeded with the same build, keyed exactly as the + * promoted-scope ask computes its key. + */ + public function seedCacheFromClosureWalk(MutatingScope $scope, Node\Expr\Closure $expr, ProcessClosureResult $processClosureResult): void + { + // for array_map() callbacks and immediately invoked closures this + // delegates to a getClosureType() walk with the call-site parameter + // types; either way the phpdoc build lands in the cache under the + // key the plain ask computes + $this->buildClosureTypeForClosure( + $scope, + $expr, + $processClosureResult->getGatheredReturnStatements(), + $processClosureResult->getGatheredYieldStatements(), + $processClosureResult->getExecutionEnds(), + $processClosureResult->getThrowPoints(), + $processClosureResult->getClosureTypeImpurePoints(), + $processClosureResult->getInvalidateExpressions(), + ); + + [$parameters, , $callableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + $phpdocKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); + $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + if (!array_key_exists($phpdocKey, $cachedTypes)) { + return; + } + + $promotedScope = $scope->doNotTreatPhpDocTypesAsCertain(); + [$promotedParameters, , $promotedCallableParameters] = $this->buildParametersAndAcceptors($promotedScope, $expr); + $cachedTypes[$this->closureContextCacheKey($promotedScope, $expr, $promotedCallableParameters, $promotedParameters)] = $cachedTypes[$phpdocKey]; + $expr->setAttribute('phpstanCachedTypes', $cachedTypes); + } + /** * The cache key of everything this closure's type can depend on: the * enclosing scope plus the parameter types the caller feeds in - @@ -730,7 +771,7 @@ private function assembleClosureType( $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey ??= $scope->getClosureScopeCacheKey(); + $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); $cachedTypes[$cacheKey] = [ 'returnType' => $returnType, 'throwPoints' => $throwPointsForClosureType, From 5aaeca6935a2a109eb0e60da6d489182ea048ca7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 22:16:48 +0200 Subject: [PATCH 4/5] Seed the arrow function type cache from the handler's single body walk processArrowFunctionNode() now returns ProcessArrowFunctionResult: the walked arrow scope plus the throw points, impure points (merged in getClosureType() order) and invalidate expressions its type needs, gathered during the single body walk. ArrowFunctionHandler seeds the per-node type cache with both flavours built from the walk - unlike a closure, an arrow function's native return type genuinely differs from its phpdoc one, so the native slot gets its own build reading the walked body's native types. Arrow functions now participate in the cache read: the flavour- and parameter-aware key answers each ask from its own seeded slot. Both seeding paths (this one and the closure one) are gated on the storage having no parked fibers: a parked fiber may still append to the walk's gathered data - the invalidate expressions of a write like $this->prop[] = ... arrive only when the fiber flushes - so a seeded entry could be incomplete. When anything is parked, the lazy ask keeps re-walking with the fibers flushed, as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- .../ExprHandler/ArrowFunctionHandler.php | 4 +- src/Analyser/ExprHandler/ClosureHandler.php | 2 +- .../Helper/ClosureTypeResolver.php | 48 ++++++++++++++- src/Analyser/NodeScopeResolver.php | 48 +++++++++++++-- src/Analyser/ProcessArrowFunctionResult.php | 59 +++++++++++++++++++ 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/Analyser/ProcessArrowFunctionResult.php diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 5390ee8e212..564d7e90cab 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -41,7 +41,9 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $result = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $this->closureTypeResolver->seedCacheFromArrowFunctionWalk($scope, $expr, $arrowFunctionResult, $storage); + $result = $arrowFunctionResult->getExpressionResult(); return $this->expressionResultFactory->create( $result->getScope(), diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 07f600f69d4..683ebfc8859 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -42,7 +42,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); - $this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult); + $this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult, $storage); return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index c1df4c77096..b461708e799 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\ProcessArrowFunctionResult; use PHPStan\Analyser\ProcessClosureResult; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; @@ -87,7 +88,7 @@ public function getClosureType( $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); - if (!$expr instanceof ArrowFunction && array_key_exists($cacheKey, $cachedTypes)) { + if (array_key_exists($cacheKey, $cachedTypes)) { return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); } if (self::$resolveClosureTypeDepth >= 2) { @@ -322,8 +323,18 @@ public function buildClosureTypeForArrowFunction( * slot is seeded with the same build, keyed exactly as the * promoted-scope ask computes its key. */ - public function seedCacheFromClosureWalk(MutatingScope $scope, Node\Expr\Closure $expr, ProcessClosureResult $processClosureResult): void + public function seedCacheFromClosureWalk(MutatingScope $scope, Node\Expr\Closure $expr, ProcessClosureResult $processClosureResult, ExpressionResultStorage $storage): void { + // a parked fiber may still append to the walk's gathered data - the + // invalidate expressions of a write like $this->prop[] = ... arrive + // only when the fiber flushes (see the invalidate-expressions note in + // NodeScopeResolver::processArgs()). Seed only when nothing is parked, + // so a seeded entry is never incomplete; otherwise the lazy ask keeps + // re-walking with the fibers flushed, as before. + if ($storage->pendingFibers !== []) { + return; + } + // for array_map() callbacks and immediately invoked closures this // delegates to a getClosureType() walk with the call-site parameter // types; either way the phpdoc build lands in the cache under the @@ -352,6 +363,39 @@ public function seedCacheFromClosureWalk(MutatingScope $scope, Node\Expr\Closure $expr->setAttribute('phpstanCachedTypes', $cachedTypes); } + /** + * Arrow-function counterpart of seedCacheFromClosureWalk() (see + * ArrowFunctionHandler::processExpr()). Unlike a closure, an arrow + * function's native return type genuinely differs from its phpdoc one, + * so the native-flavour slot is seeded with its own build reading the + * walked body's native types. + */ + public function seedCacheFromArrowFunctionWalk(MutatingScope $scope, ArrowFunction $expr, ProcessArrowFunctionResult $arrowFunctionResult, ExpressionResultStorage $storage): void + { + // see the parked-fiber note in seedCacheFromClosureWalk() + if ($storage->pendingFibers !== []) { + return; + } + + $this->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowFunctionResult->getArrowFunctionScope(), + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); + $this->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowFunctionResult->getArrowFunctionScope(), + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + true, + ); + } + /** * The cache key of everything this closure's type can depend on: the * enclosing scope plus the parameter types the caller feeds in - diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 10db3ea5774..6a140099f42 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3198,7 +3198,7 @@ public function processArrowFunctionNode( callable $nodeCallback, ?Type $passedToType, ?Type $nativePassedToType = null, - ): ExpressionResult + ): ProcessArrowFunctionResult { foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -3216,9 +3216,49 @@ public function processArrowFunctionNode( throw new ShouldNotHappenException(); } $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); - $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); + // Gather the property-assign impure points and invalidate expressions the + // arrow function type needs (mirroring ClosureTypeResolver::getClosureType()), + // on top of the regular rule node callback, so the single body walk here + // feeds ClosureTypeResolver::buildClosureTypeForArrowFunction(). + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + $arrowFunctionStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $innerScope) use ($arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + if ($innerScope->getAnonymousFunctionReflection() !== $arrowFunctionScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if (!$node instanceof PropertyAssignNode) { + return; + } + + $arrowFunctionImpurePoints[] = new ImpurePoint( + $innerScope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + }, $nodeCallback); + + $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $arrowFunctionStmtsCallback, ExpressionContext::createTopLevel()); + + $closureTypeThrowPoints = array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $exprResult->getThrowPoints()); + $closureTypeImpurePoints = array_merge($arrowFunctionImpurePoints, $exprResult->getImpurePoints()); + + return new ProcessArrowFunctionResult( + $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()), + $arrowFunctionScope, + $closureTypeThrowPoints, + $closureTypeImpurePoints, + $invalidateExpressions, + ); } /** @@ -3918,7 +3958,7 @@ public function processArgs( } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType); + $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType)->getExpressionResult(); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); diff --git a/src/Analyser/ProcessArrowFunctionResult.php b/src/Analyser/ProcessArrowFunctionResult.php new file mode 100644 index 00000000000..a571ced99ff --- /dev/null +++ b/src/Analyser/ProcessArrowFunctionResult.php @@ -0,0 +1,59 @@ +expressionResult; + } + + public function getArrowFunctionScope(): MutatingScope + { + return $this->arrowFunctionScope; + } + + /** + * @return ThrowPoint[] + */ + public function getClosureTypeThrowPoints(): array + { + return $this->closureTypeThrowPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + + /** + * @return InvalidateExprNode[] + */ + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + +} From 1b15ec625aa1972ebd2557eb2f94ecbdc7a9d414 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 22:25:08 +0200 Subject: [PATCH 5/5] Seed the closure type cache from the call-argument body walks processArgs() walks closure and arrow function arguments itself; the reads that follow - the preferred ClosureType invalidate-expression read for closures and the invalidation read for arrow functions - then priced the argument through another body walk. Seeding the type cache from the walk just performed makes those reads cache hits. The reads themselves are untouched: when a parked fiber may still complete the gathered data, the seed is skipped and they keep re-walking with the fibers flushed, preserving the completeness contract documented at the closure read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/NodeScopeResolver.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 6a140099f42..cf0cba7f778 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -49,6 +49,7 @@ use PhpParser\NodeFinder; use PhpParser\NodeTraverser; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; @@ -3875,6 +3876,10 @@ public function processArgs( $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType, $parameterNativeType); + // the preferred ClosureType read below now answers from this seed + // instead of walking the body again (unless a parked fiber may + // still complete the gathered data - then it keeps re-walking) + $this->container->getByType(ClosureTypeResolver::class)->seedCacheFromClosureWalk($scopeToPass, $arg->value, $closureResult, $storage); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); @@ -3958,7 +3963,12 @@ public function processArgs( } $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType)->getExpressionResult(); + $processArrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType); + // the invalidation read below now answers from this seed instead + // of walking the body again (unless a parked fiber may still + // complete the gathered data - then it keeps re-walking) + $this->container->getByType(ClosureTypeResolver::class)->seedCacheFromArrowFunctionWalk($scopeToPass, $arg->value, $processArrowFunctionResult, $storage); + $arrowFunctionResult = $processArrowFunctionResult->getExpressionResult(); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints());