From 72b0c53c431b15044defd734ae4e40a68cf0d699 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 9 Jun 2026 15:29:22 +0200 Subject: [PATCH 01/50] Guard old-world type resolution entry points behind NewWorld::disableOldWorld() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MutatingScope::getType()/getNativeType()/getKeepVoidType() and TypeSpecifier::specifyTypesInCondition() throw when the switch returns true (and PHPSTAN_FNSR != 0, PHP 8.1+) — the migration meter for moving type resolution and narrowing onto single-pass ExpressionResult callbacks. The committed state is `return false;`: mixed mode, where migrated handlers run their callbacks, everything else takes the legacy old-world bridges, and the whole test suite stays green throughout the rewrite. A handler-migration leg starts by flipping the literal to true so the guard names whatever still needs the new callbacks. --- src/Analyser/MutatingScope.php | 16 ++++++++++++++++ src/Analyser/NewWorld.php | 32 ++++++++++++++++++++++++++++++++ src/Analyser/TypeSpecifier.php | 7 +++++++ 3 files changed, 55 insertions(+) create mode 100644 src/Analyser/NewWorld.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0980fb6936..1b07b3dc3a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -120,6 +120,7 @@ use function count; use function explode; use function get_class; +use function getenv; use function implode; use function in_array; use function is_array; @@ -896,6 +897,11 @@ public function getAnonymousFunctionReturnType(): ?Type /** @api */ public function getType(Expr $node): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getType() should not be used here. Either FiberScope::getType() will be used (by extensions), or ExpressionResult::getType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + $key = $this->getNodeKey($node); if (!array_key_exists($key, $this->resolvedTypes)) { @@ -1165,11 +1171,21 @@ private function issetCheckUndefined(Expr $expr): ?bool /** @api */ public function getNativeType(Expr $expr): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getNativeType() should not be used here. Either FiberScope::getNativeType() will be used (by extensions), or ExpressionResult::getNativeType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + return $this->promoteNativeTypes()->getType($expr); } public function getKeepVoidType(Expr $node): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getKeepVoidType() should not be used here. Either FiberScope::getKeepVoidType() will be used (by extensions), or ExpressionResult::getKeepVoidType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + if ( !$node instanceof Match_ && ( diff --git a/src/Analyser/NewWorld.php b/src/Analyser/NewWorld.php new file mode 100644 index 0000000000..d555f78874 --- /dev/null +++ b/src/Analyser/NewWorld.php @@ -0,0 +1,32 @@ += 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('TypeSpecifier should not be used here. Ask ExpressionResult for SpecifiedTypes instead.'); + } + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { return (new SpecifiedTypes([], []))->setRootExpr($expr); } From cdcb73f86569a0a0172d455115138decf386f5a3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 9 Jun 2026 20:41:41 +0200 Subject: [PATCH 02/50] Deliver ExpressionResult (with type callbacks) to FiberScope and engine Carry a lazy typeCallback and specifyTypesCallback on ExpressionResult so an expression's Type and SpecifiedTypes are available after processExprNode finishes, without re-traversing via MutatingScope::getType or TypeSpecifier. - ExpressionResult: getType/getNativeType/getTypeForScope (#5224-style single scope-arg callback), getSpecifiedTypes, and getTruthyScope/getFalseyScope rebuilt on top of it. When a handler has not supplied a callback, getType falls back to $scope->getType (legacy bridge: works under PHPSTAN_FNSR=0, hits the guard under FNSR=1). - Store ExpressionResult per Expr; FiberScope::getType/getNativeType now suspend for the whole ExpressionResult (ExpressionResultForExprRequest, renamed) and resume at the end of processExprNode via storeResult; base storeResult populates storage so findResult works without fibers too. - ImplicitToStringCallHelper takes the resolved Type from the caller's child ExpressionResult; findEarlyTerminatingExpr takes the result type. - Migrate ScalarHandler, VariableHandler and AssignHandler to supply the type callback (Assign reads its RHS type/native-type from the stored result). echo '1' passes at level 8 under the guard; FNSR=0 stays on the legacy path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Analyser/ExprHandler/AssignHandler.php | 26 +++- src/Analyser/ExprHandler/AssignOpHandler.php | 6 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 4 +- .../ExprHandler/CastStringHandler.php | 2 +- .../Helper/ImplicitToStringCallHelper.php | 5 +- .../ExprHandler/InterpolatedStringHandler.php | 2 +- src/Analyser/ExprHandler/PrintHandler.php | 2 +- src/Analyser/ExprHandler/ScalarHandler.php | 5 + src/Analyser/ExprHandler/VariableHandler.php | 10 ++ src/Analyser/ExpressionResult.php | 147 ++++++++++++++++++ src/Analyser/ExpressionResultStorage.php | 21 ++- ...php => ExpressionResultForExprRequest.php} | 2 +- src/Analyser/Fiber/FiberNodeScopeResolver.php | 42 +++-- src/Analyser/Fiber/FiberScope.php | 30 ++-- src/Analyser/NodeScopeResolver.php | 30 +++- 15 files changed, 280 insertions(+), 54 deletions(-) rename src/Analyser/Fiber/{BeforeScopeForExprRequest.php => ExpressionResultForExprRequest.php} (84%) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 540119391e..862be350d4 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -38,6 +38,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; @@ -95,6 +96,7 @@ public function __construct( private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, ) { } @@ -388,6 +390,16 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex impurePoints: $result->getImpurePoints(), truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($storage): Type { + /** @var Assign|AssignRef $e */ + $assignedExprResult = $storage->findResult($e->expr); + if ($assignedExprResult === null) { + throw new ShouldNotHappenException(); + } + return $assignedExprResult->getTypeForScope($s); + }, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } @@ -428,7 +440,14 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $scopeBeforeAssignEval->getType($assignedExpr); + // A plain Assign's value is processed via processExprNode and stored, + // so its (possibly migrated) type comes from the ExpressionResult. + // AssignOp and other not-separately-processed values fall back to the + // legacy scope type (works under PHPSTAN_FNSR=0, guarded under FNSR=1). + $assignedExprResult = $storage->findResult($assignedExpr); + $type = $assignedExprResult !== null + ? $assignedExprResult->getType() + : $scopeBeforeAssignEval->getType($assignedExpr); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { @@ -510,7 +529,10 @@ public function processAssignVar( } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); + $assignedNativeType = $assignedExprResult !== null + ? $assignedExprResult->getNativeType() + : $scopeBeforeAssignEval->getNativeType($assignedExpr); + $scope = $scope->assignVariable($var->name, $type, $assignedNativeType, TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 31af0b9c77..83a6fec28d 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -100,7 +100,11 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $exprResult = $storage->findResult($expr->expr); + if ($exprResult === null) { + throw new ShouldNotHappenException(); + } + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 0f604c8644..658482be9e 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -94,8 +94,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); - $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $leftResult->getType(), $scope); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $rightResult->getType(), $leftResult->getScope()); $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 4fdfc9adfc..23c24a6ca2 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -48,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index eab62846f8..ad0b5a31ed 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; +use PHPStan\Type\Type; use function sprintf; #[AutowiredService] @@ -23,13 +24,11 @@ public function __construct( { } - public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult + public function processImplicitToStringCall(Expr $expr, Type $exprType, MutatingScope $scope): ExpressionResult { $throwPoints = []; $impurePoints = []; - $exprType = $scope->getType($expr); - $toStringMethod = null; if (!$exprType->isObject()->no()) { $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 51de44f579..8d6a983d29 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $partResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 9d8b220ccf..cf2cfd748b 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index abd0dc63a4..98c2137ed4 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; @@ -30,6 +31,7 @@ final class ScalarHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, ) { } @@ -47,6 +49,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->initializerExprTypeResolver->getType($e, InitializerExprContext::fromScope($s)), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index e104b9fed4..4938dc24ba 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -34,6 +35,12 @@ final class VariableHandler implements ExprHandler { + public function __construct( + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Variable; @@ -97,6 +104,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints, static fn (): MutatingScope => $scope->filterByTruthyValue($expr), static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->resolveType($s, $e), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 746c518953..6f5db5c71c 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,9 +2,23 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; +use PHPStan\Type\TypeUtils; +use function get_class; +use function sprintf; + final class ExpressionResult { + /** @var (callable(Expr, MutatingScope): Type)|null */ + private $typeCallback; + + /** @var (callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ + private $specifyTypesCallback; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -15,9 +29,15 @@ final class ExpressionResult private ?MutatingScope $falseyScope = null; + private ?Type $cachedType = null; + + private ?Type $cachedNativeType = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints + * @param (callable(Expr, MutatingScope): Type)|null $typeCallback + * @param (callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback */ @@ -29,10 +49,27 @@ public function __construct( private array $impurePoints, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, + private ?Expr $expr = null, + ?callable $typeCallback = null, + ?callable $specifyTypesCallback = null, + private ?ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider = null, ) { $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; + $this->typeCallback = $typeCallback; + $this->specifyTypesCallback = $specifyTypesCallback; + } + + /** + * Attaches the processed Expr to results coming from not-yet-migrated handlers, + * enabling the legacy type-resolution bridge. Called by NodeScopeResolver::processExprNode(). + * + * @internal + */ + public function setExpr(Expr $expr): void + { + $this->expr ??= $expr; } public function getScope(): MutatingScope @@ -61,8 +98,77 @@ public function getImpurePoints(): array return $this->impurePoints; } + /** + * `ExpressionResult::getType()` is a replacement for `MutatingScope::getType(Expr)` + * for use inside `ExprHandler::processExpr()` implementations. + */ + public function getType(): Type + { + if ($this->cachedType !== null) { + return $this->cachedType; + } + + return $this->cachedType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($this->scope)); + } + + /** + * `ExpressionResult::getNativeType()` is a replacement for `MutatingScope::getNativeType(Expr)` + * for use inside `ExprHandler::processExpr()` implementations. + */ + public function getNativeType(): Type + { + if ($this->cachedNativeType !== null) { + return $this->cachedNativeType; + } + + if ($this->typeCallback === null) { + if ($this->expr === null) { + throw new ShouldNotHappenException('ExpressionResult native type was requested but no Expr is attached.'); + } + + // Legacy bridge for not-yet-migrated handlers. Guarded: + // works under PHPSTAN_FNSR=0, throws the guarding exception otherwise. + return $this->cachedNativeType = $this->scope->getNativeType($this->expr); + } + + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($this->scope->doNotTreatPhpDocTypesAsCertain())); + } + + /** + * Used instead of `$scope->getType(Expr)` inside the `typeCallback`. The passed scope + * only selects the variant (native types when `nativeTypesPromoted`); the type itself + * is resolved on this result's own (already-correct) scope. + */ + public function getTypeForScope(MutatingScope $scope): Type + { + if ($scope->nativeTypesPromoted) { + return $this->getNativeType(); + } + + return $this->getType(); + } + + public function getSpecifiedTypes(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($this->expr === null || $this->specifyTypesCallback === null) { + throw new ShouldNotHappenException(sprintf( + 'ExpressionResult specifyTypes was requested but the handler for %s has not been migrated.', + $this->expr === null ? 'this expression' : get_class($this->expr), + )); + } + + $callback = $this->specifyTypesCallback; + return $callback($this->expr, $scope, $context); + } + public function getTruthyScope(): MutatingScope { + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->scope->filterBySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), + ); + } + if ($this->truthyScopeCallback === null) { return $this->scope; } @@ -78,6 +184,12 @@ public function getTruthyScope(): MutatingScope public function getFalseyScope(): MutatingScope { + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->scope->filterBySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), + ); + } + if ($this->falseyScopeCallback === null) { return $this->scope; } @@ -96,4 +208,39 @@ public function isAlwaysTerminating(): bool return $this->isAlwaysTerminating; } + private function getTypeByScope(MutatingScope $scope): Type + { + if ($this->expr === null) { + throw new ShouldNotHappenException('ExpressionResult type was requested but no Expr is attached.'); + } + + if ($this->typeCallback === null) { + // Legacy bridge for not-yet-migrated handlers. Guarded: + // works under PHPSTAN_FNSR=0, throws the guarding exception otherwise. + return $scope->getType($this->expr); + } + + if ($this->expressionTypeResolverExtensionRegistryProvider !== null) { + foreach ($this->expressionTypeResolverExtensionRegistryProvider->getRegistry()->getExtensions() as $extension) { + $type = $extension->getType($this->expr, $scope); + if ($type !== null) { + return $type; + } + } + } + + if ( + !$this->expr instanceof Expr\Variable + && !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes() + ) { + $exprString = $scope->getNodeKey($this->expr); + return $scope->expressionTypes[$exprString]->getType(); + } + + $callback = $this->typeCallback; + return $callback($this->expr, $scope); + } + } diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index d14923866c..f1e8ef6301 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -5,7 +5,7 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; -use PHPStan\Analyser\Fiber\BeforeScopeForExprRequest; +use PHPStan\Analyser\Fiber\ExpressionResultForExprRequest; use PHPStan\Analyser\Fiber\ParkFiberRequest; use SplObjectStorage; @@ -15,21 +15,26 @@ final class ExpressionResultStorage /** @var SplObjectStorage */ private SplObjectStorage $scopes; - /** @var array, request: BeforeScopeForExprRequest}> */ + /** @var SplObjectStorage */ + private SplObjectStorage $results; + + /** @var array, request: ExpressionResultForExprRequest}> */ public array $pendingFibers = []; - /** @var list> */ + /** @var list> */ public array $parkedFibers = []; public function __construct() { $this->scopes = new SplObjectStorage(); + $this->results = new SplObjectStorage(); } public function duplicate(): self { $new = new self(); $new->scopes->addAll($this->scopes); + $new->results->addAll($this->results); return $new; } @@ -43,4 +48,14 @@ public function findBeforeScope(Expr $expr): ?Scope return $this->scopes[$expr] ?? null; } + public function storeResult(Expr $expr, ExpressionResult $result): void + { + $this->results[$expr] = $result; + } + + public function findResult(Expr $expr): ?ExpressionResult + { + return $this->results[$expr] ?? null; + } + } diff --git a/src/Analyser/Fiber/BeforeScopeForExprRequest.php b/src/Analyser/Fiber/ExpressionResultForExprRequest.php similarity index 84% rename from src/Analyser/Fiber/BeforeScopeForExprRequest.php rename to src/Analyser/Fiber/ExpressionResultForExprRequest.php index 0fc6ecd35c..767f334de2 100644 --- a/src/Analyser/Fiber/BeforeScopeForExprRequest.php +++ b/src/Analyser/Fiber/ExpressionResultForExprRequest.php @@ -5,7 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\MutatingScope; -final class BeforeScopeForExprRequest +final class ExpressionResultForExprRequest { public function __construct(public readonly Expr $expr, public readonly MutatingScope $scope) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index e8d160f6a1..9ffa1408fb 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -5,6 +5,8 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionContext; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -48,26 +50,26 @@ public function callNodeCallback( $this->runFiberForNodeCallback($storage, $fiber, $request); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + public function storeResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void { - $storage->storeBeforeScope($expr, $beforeScope); - $this->processPendingFibersForRequestedExpr($storage, $expr, $beforeScope); + parent::storeResult($storage, $expr, $result); + $this->processPendingFibersForRequestedExpr($storage, $expr, $result); } /** - * @param Fiber $fiber + * @param Fiber $fiber */ private function runFiberForNodeCallback( ExpressionResultStorage $storage, Fiber $fiber, - BeforeScopeForExprRequest|ParkFiberRequest|null $request, + ExpressionResultForExprRequest|ParkFiberRequest|null $request, ): void { while (!$fiber->isTerminated()) { - if ($request instanceof BeforeScopeForExprRequest) { - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { - $request = $fiber->resume($beforeScope); + if ($request instanceof ExpressionResultForExprRequest) { + $result = $storage->findResult($request->expr); + if ($result !== null) { + $request = $fiber->resume($result); continue; } @@ -100,24 +102,34 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void foreach ($storage->pendingFibers as $key => $pending) { $request = $pending['request']; - $beforeScope = $storage->findBeforeScope($request->expr); - - if ($beforeScope !== null) { + if ($storage->findResult($request->expr) !== null) { throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } unset($storage->pendingFibers[$key]); + // Synthetic node: never visited by traversal, so produce its ExpressionResult now + // on the scope captured at suspension time. + $result = $this->processExprNode( + new Node\Stmt\Expression($request->expr), + $request->expr, + $request->scope, + $storage, + static function (): void { + }, + ExpressionContext::createDeep(), + ); + $fiber = $pending['fiber']; - $request = $fiber->resume($request->scope); - $this->runFiberForNodeCallback($storage, $fiber, $request); + $nextRequest = $fiber->resume($result); + $this->runFiberForNodeCallback($storage, $fiber, $nextRequest); // Break and restart the loop since the array may have been modified goto start; } } - private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, Scope $result): void + private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void { start: diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index b02f322c35..2525decb91 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -4,6 +4,7 @@ use Fiber; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; @@ -57,13 +58,12 @@ public function toMutatingScope(): MutatingScope /** @api */ public function getType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $result */ + $result = Fiber::suspend( + new ExpressionResultForExprRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - return $scope->getType($node); + return $result->getTypeForScope($this); } public function getScopeType(Expr $expr): Type @@ -79,25 +79,23 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($expr, $this), + /** @var ExpressionResult $result */ + $result = Fiber::suspend( + new ExpressionResultForExprRequest($expr, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - return $scope->getNativeType($expr); + return $result->getNativeType(); } public function getKeepVoidType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $result */ + $result = Fiber::suspend( + new ExpressionResultForExprRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - - return $scope->getKeepVoidType($node); + // keepVoid is a one-off we will solve separately; fall back to the regular type for now. + return $result->getTypeForScope($this); } public function filterByTruthyValue(Expr $expr): self diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 41fdf89072..a408ce5470 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -358,6 +358,11 @@ public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, S { } + public function storeResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void + { + $storage->storeResult($expr, $result); + } + protected function processPendingFibers(ExpressionResultStorage $storage): void { } @@ -1059,7 +1064,7 @@ public function processStmtNode( $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $result->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); @@ -1118,7 +1123,6 @@ public function processStmtNode( if ($stmt->expr instanceof Expr\Throw_) { $scope = $stmtScope; } - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $hasAssign = false; $currentScope = $scope; $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { @@ -1131,6 +1135,7 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope, $result->getType()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -2672,7 +2677,7 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr + private function findEarlyTerminatingExpr(Expr $expr, Scope $scope, Type $exprType): ?Expr { if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { @@ -2715,7 +2720,6 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return $expr; } - $exprType = $scope->getType($expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { return $expr; } @@ -2749,7 +2753,9 @@ public function processExprNode( throw new ShouldNotHappenException(); } - return $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $result = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $this->storeResult($storage, $expr, $result); + return $result; } $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); @@ -2760,15 +2766,20 @@ public function processExprNode( continue; } - return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); + $result = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); + $result->setExpr($expr); + $this->storeResult($storage, $expr, $result); + return $result; } if ($expr instanceof List_) { // only in assign and foreach, processed elsewhere - return new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + $result = new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $expr); + $this->storeResult($storage, $expr, $result); + return $result; } - return new ExpressionResult( + $result = new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, @@ -2776,7 +2787,10 @@ public function processExprNode( impurePoints: [], truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, ); + $this->storeResult($storage, $expr, $result); + return $result; } /** From 2bea2409a492458abc5b91ff628d496c16da0d44 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 09:21:34 +0200 Subject: [PATCH 03/50] Add NEW_WORLD.md design document --- NEW_WORLD.md | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 NEW_WORLD.md diff --git a/NEW_WORLD.md b/NEW_WORLD.md new file mode 100644 index 0000000000..8998463549 --- /dev/null +++ b/NEW_WORLD.md @@ -0,0 +1,229 @@ +# The New World: single-pass expression processing + +Working design document for the `resolve-type-rewrite` branch. This describes where the +refactoring is going, why, what we gain, and the full inventory of code to build. It is a +branch-lifetime document — delete before merging to the default branch. + +## 1. Motivation + +PHPStan currently traverses the AST of the same expression **multiple times**: + +1. **`NodeScopeResolver::processExprNode`** walks the expression to update the `Scope` + (assignments, narrowing side-effects, throw/impure points). +2. **`MutatingScope::resolveType`** (via `ExprHandler::resolveType`) walks the expression + *again* to compute its `Type` — on whatever scope the caller happens to hold. +3. **`TypeSpecifier::specifyTypesInCondition`** (via `ExprHandler::specifyTypes`) walks it + a *third* time to compute narrowing (`SpecifiedTypes`). + +Because pass 2 and 3 don't have the intermediate scopes that pass 1 computed, they have to +**re-create them**, which means re-invoking the engine from inside type resolution. Concrete +pathologies in today's code: + +- `BooleanAndHandler::resolveType` re-runs `processExprNode($expr->left)` on a **throwaway + `ExpressionResultStorage`** with a `NoopNodeCallback`, just to rebuild the truthy scope of + the left side so it can type the right side — even though `processExpr` four lines earlier + already processed the right side on exactly that scope. The cost is exponential on deep + boolean chains, which is the only reason `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH` and the + flattened-chain code paths exist. +- `AssignHandler::unwrapAssign` manually walks through nested `$a = $b = 5` because resolving + the type via the scope can't follow the chain naturally. +- `TypeSpecifier` is a condition-*rewriting* engine: handlers build **synthetic** expressions + (`new Identical(...)`, isset and-chains, cast comparisons, swapped `Smaller` nodes) and + re-enter the dispatcher, because narrowing logic can only talk to other narrowing logic + through "an expression + a scope". +- `MutatingScope` keeps `truthyScopes`/`falseyScopes` caches and `FiberScope` keeps a + truthy/falsey expr replay list (`preprocessScope`) purely to paper over the fact that + narrowing is recomputed after the fact instead of being produced once, in place. + +**The fix: process each expression once.** After `processExprNode` finishes we have not just +the updated `Scope` but also the expression's `Type` and its `SpecifiedTypes` — computed by +the handler *at the moment it had all its children's results and the correct intermediate +scopes in hand*. + +## 2. Old world vs. new world + +The old world keeps working on PHP < 8.1 until PHPStan 3.0, where it is mass-deleted. +The new world requires PHP 8.1+ (Fibers). + +| Old world (deleted in 3.0) | New world | +|---|---| +| `MutatingScope::getType/getNativeType/resolveType` | `ExpressionResult::getType()/getNativeType()/getTypeForScope()` | +| `ExprHandler::resolveType` | `typeCallback` wired in `processExpr` | +| `TypeSpecifier::specifyTypesInCondition` dispatcher + `ExprHandler::specifyTypes` | `specifyTypesCallback` wired in `processExpr` | +| `MutatingScope::filterBySpecifiedTypes` | `MutatingScope::applySpecifiedTypes` | +| `filterByTruthyValue` / `filterByFalseyValue` (+ `truthyScopes` caches) | `applySpecifiedTypes($result->getSpecifiedTypes(...))` | +| `ExpressionResult` legacy `truthyScopeCallback`/`falseyScopeCallback` | `getTruthyScope()`/`getFalseyScope()` reimplemented on the line above (accessors stay; ~31 engine call sites untouched) | +| `FiberScope::preprocessScope` truthy/falsey replay | not needed — narrowing applied to real scopes | + +Enforcement: `MutatingScope::getType()/getNativeType()/getKeepVoidType()` and +`TypeSpecifier::specifyTypesInCondition()` **throw** when `NewWorld::disableOldWorld()` +returns `true` (and `PHPSTAN_FNSR` ≠ `0`, PHP 8.1+ — those conditions stay at the call +sites). The old world stays fully functional under `PHPSTAN_FNSR=0` (PHP < 8.1 path). +**The committed state is `return false;`** — mixed mode, everything green. Flipping the +single literal to `return true;` is how a handler-migration leg starts: the guard then +fails loudly wherever migration is incomplete, instead of commenting throws in four places. + +**The goal of this continuous refactoring: the whole test suite is green when the guard +exceptions are set to not fire** (mixed mode — migrated handlers run their new-world +callbacks, everything else takes the guarded legacy bridges). That bar means the rewrite +pays off *before* it is finished: every migrated handler immediately delivers improved and +more precise analysis across the whole test suite, not just in the new-world corpus. The +guard-on mode is the forcing function and the progress meter; the mixed mode is the +deliverable at every point along the way. + +## 3. Design decisions (settled) + +1. **`typeCallback: callable(Expr, MutatingScope): Type`** — one callback, mirroring PR #5224 + (`b2ce1a0558`). `getType()` resolves on the result's own scope; `getNativeType()` on + `doNotTreatPhpDocTypesAsCertain()`; `getTypeForScope($scope)` picks the variant by + `$scope->nativeTypesPromoted`. No separate native/keepVoid callbacks; `getKeepVoidType` + is a one-off solved later. +2. **Inside callbacks, `$scope->getType($child)` becomes `$childResult->getTypeForScope($s)`.** + `MutatingScope::getType()` must never be called from inside an `ExprHandler`. +3. **Never reach into `ExpressionResultStorage` from handler logic.** Child results are + threaded through closures. Storage exists only as the fiber rendezvous (deliver results to + suspended rule callbacks) and the synthetic-node fallback. Every constructed + `ExpressionResult` carries its `expr` so `getType()` always works. +4. **Hard-fail + guarded legacy bridge.** A result without a callback falls back to the + guarded `$scope->getType($expr)`: transparent under `PHPSTAN_FNSR=0` (validated parity vs + baseline on stress files), loud failure under the guard. This is what makes + handler-by-handler migration safe — the suite stays green on the legacy path while the + guard tells us exactly what to migrate next. +5. **New code paths instead of nullable/optional params** on existing methods (no + `?Type $exprType` threading through `TypeSpecifier::create`; `SpecifiedTypes` stays + untouched — it is `@api` and extensions produce it forever). +6. **Copy-and-adjust is sanctioned**: `resolveType` bodies are copied into `typeCallback` + (and `specifyTypes` into `specifyTypesCallback`) with the §3.2 substitution. Dual + maintenance until 3.0 is the accepted cost; mitigate by extracting pure `Type`-taking + helpers shared by both worlds. +7. **`specifyTypesCallback` returns a new envelope object** (working name `NarrowingResult`): + `SpecifiedTypes` + `array` (exprString → result). The map is the + "type oracle": it answers original (pre-narrowing) types in `applySpecifiedTypes` and + `normalize()`, and supplies dim/var types for the `ArrayDimFetch` parent-update — all via + `ExpressionResult::getType()`, honoring §3.3. Extension-produced `SpecifiedTypes` flow + through with an empty map. +8. **Two adapters, by execution context**: + - **`FiberScope`** (exists): for *rule* node-callbacks, which run before the expression is + processed. `getType()` suspends the fiber; the engine resumes it with the + `ExpressionResult` at the end of `processExprNode`. Synthetic exprs are processed on + demand at end of traversal. + - **`ResultAwareScope`** (to build): for *extensions and old-world helper code invoked from + inside handler callbacks* — dynamic return type extensions, type-specifying extensions, + `ParametersAcceptorSelector::selectFromArgs`, `TypeSpecifier::create`, assert resolution. + These run mid-analysis where suspension is impossible *and unnecessary*: all children are + already processed. `getType()` resolves in tiers: extension registry → scope-tracked + holder → known-results map → inline re-process (`processExprNode` on a duplicated + storage with `NoopNodeCallback` — handles the synthetic exprs extensions love to build) + → guarded bridge. + +## 4. What we gain + +- **Performance**: one traversal instead of up to three. The `BooleanAnd::resolveType` + re-processing (and its depth cap), the `filterByTruthyValue` recomputation cascades, and the + `truthyScopes` cache layer all disappear. #5224 measured ~17% on a comparable consolidation. + Types are computed from already-known child types instead of re-walking subtrees. +- **Correctness by construction**: a type is computed exactly where the right scope exists. + No more "which scope do I resolve this on" bugs; the right side of `&&` is typed on the + left-truthy scope because that is literally the scope it was processed on. +- **Simplicity — hacks that delete themselves**: + - `unwrapAssign` (nested assigns flow through result delegation), + - `BooleanAndHandler::resolveType` re-walk + `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH` + flattened-chain workarounds, + - synthetic re-dispatch nodes inside `specifyTypes` bodies (`new Identical(...)`, isset chains), + - `AssignHandler`'s Ternary lookahead on `$storage->duplicate()`, + - `truthyScopes`/`falseyScopes` caches, `FiberScope::preprocessScope` replay, + - `storeBeforeScope`/`findBeforeScope` (already dead), + - in 3.0: all `resolveType`/`specifyTypes` methods, `MutatingScope::resolveType`, + the `TypeSpecifier` dispatcher, `filterBySpecifiedTypes`, `filterByTruthy/FalseyValue`. +- **Extension compatibility preserved**: third-party extensions keep their signatures. + `Scope::getType` works inside extensions via `ResultAwareScope`/`FiberScope`; + `TypeSpecifier::specifyTypesInCondition` recursion works via an `instanceof` head-check + routing to the new world. + +## 5. Implementation inventory + +Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive + +### A. Core contracts +1. ✅ `ExpressionResult::getType/getNativeType/getTypeForScope` + guarded bridge; results + stored per expr; fiber delivery moved to end of `processExprNode` (`storeResult`). +2. 🎯 `NarrowingResult` envelope (§3.7) + result-based `normalize()`. +3. 🔶 `expr:` on every `ExpressionResult` construction; memoize truthy/falsey applied scopes; + `getSpecifiedTypes` returns the envelope. + +### B. Adapters +4. 🎯 `ResultAwareScope` + factory (§3.8) — unlocks call handlers and all extensions. +5. 🔧 `TypeSpecifier::specifyTypesInCondition` head-check: `ResultAwareScope` → map/inline-process; + `FiberScope` → suspend. Un-guards `AssertFunctionTypeSpecifyingExtension`, + `InArrayFunctionTypeSpecifyingExtension`, `ImpossibleCheckTypeHelper:305`. +6. 🔧 `FiberScope` gaps: `doNotTreatPhpDocTypesAsCertain()` override (today it escapes to a + plain promoted `MutatingScope`), `filterByTruthy/FalseyValue` → suspend + apply, + re-process request for `getScopeType` (maintainer). + +### C. applySpecifiedTypes +7. 🎯 `MutatingScope::applySpecifiedTypes(NarrowingResult): self` — original types via tiers + (extensions → tracked holder → envelope result → bridge); intersect/remove math + + complex-union/`NeverType` early-outs stay centralized (extensions force sure/sureNot + semantics to survive); post-narrowing holders computed locally (kills `getScopeType` at + `MutatingScope:3412`); `IssetExpr` entries → existing certainty ops (already clean). +8. 🔧 New-world path for the `ArrayDimFetch` parent-update in `specifyExpressionType` + (`MutatingScope:2860-2886`): dim/var types from the envelope map. +9. 🔧 `ExpressionResult::getTruthyScope/getFalseyScope` reimplemented on #7 (+ memoization). + +### D. specifyTypesCallback producers +10. 🔧 Leaf default narrowing helper (new path; copy-adjusted + `handleDefaultTruthyOrFalseyContext`/`createForExpr` taking the own type from the result). +11. 🎯 Result-based entry points on `EqualityTypeSpecifyingHelper` (replacing its 7 + `new Identical(...)` re-dispatches), `NonNullabilityHelper`, `NullsafeShortCircuitingHelper`, + `ConditionalExpressionHolderHelper`. +12. 🔧 Compound handlers composing child envelopes at the scopes they were already processed + on: `BooleanNot/And/Or` (incl. flattened variants), `ErrorSuppress`, `Ternary`, `Coalesce`, + `Isset`/`Empty` (compose parts instead of building synthetic chains), `Instanceof`, + `BinaryOp` equality/comparisons, casts. +13. 🔧 Call handlers: type-specifying extensions + conditional-return + asserts via the + adapter; `Assign`/`AssignOp` (createNull from RHS envelope, truthy/falsey via #10). + +### E. typeCallback producers +14. ✅ `Scalar`, `Variable`, `Assign` (Assign re-threaded to avoid storage). +15. 🔧 Trivial: `ConstFetch`, `Print`/`Exit`/`Throw` (fixed types), `Clone`, `ErrorSuppress`, + `Empty`/`Isset`/`Instanceof`/`BooleanNot` (booleans), 15 `Virtual/*` passthroughs. +16. 🔧 `InitializerExprTypeResolver`-backed (it is **already `callable(Expr): Type`- + parameterized** — 82 occurrences): `BinaryOp`, casts, `UnaryMinus/Plus`, `BitwiseNot`, + `InterpolatedString`, `Array_`, `ClassConstFetch`. +17. 🔧 Compound control flow: `BooleanAnd/Or`, `Ternary`, `Coalesce`, `Match` — children are + already processed per-branch; combine child results, delete the re-entry blocks. +18. 🎯 Calls: `FuncCall`/`MethodCall`/`StaticCall`/`New_`/nullsafe — return type extensions + + generics inference (`selectFromArgs`) via the adapter; `PropertyFetch`/`StaticPropertyFetch`; + `Closure`/`ArrowFunction` (existing `ClosureTypeResolver`); `Pre/PostInc/Dec`, `AssignOp`, + `ArrayDimFetch`, `Yield`/`YieldFrom`, `Eval`, `Include`, `Pipe`. + +### F. Engine rewiring +19. 🔶 `NodeScopeResolver` statements: 31 `scope->getType/getNativeType` sites → the result in + hand (`treatPhpDocTypesAsCertain ? getType : getNativeType` maps 1:1 onto + `getType()/getNativeType()`); `:1151` createNull → envelope; 9 `filterBy*` sites — the + synthetic-condition ones (switch `:2023/2049`, foreach `:1462`, while `:1626`) become + direct helper calls with results. (`findEarlyTerminatingExpr` already migrated.) + +## 6. Migration mechanics + +- **Exercisers**: tiny files analysed with `bin/phpstan analyse -l 8 test.php --debug` under + the guard. `echo '1';` (type slice, green), `$v = 1; if ($v) {} else {}` (narrowing slice). +- **New-world test case** (`NewWorldTypeInferenceTest` + `data/new-world.php`): a temporary + `TypeInferenceTestCase` subclass asserting types for both migrated handlers and the bridges. + Its diagnostic value is **when the old world is cut off by the guard exceptions**: run it + with the guards active and the failures show exactly which handlers still need to implement + the new callbacks (the guard messages name the construct). In the mixed working state it + must stay fully green. **When the whole suite is green in mixed mode, the temporary test + case is deleted** — everything is covered by pre-existing tests. +- **Parity discipline**: after each migration leg, `PHPSTAN_FNSR=0` runs must match baseline + (`git stash` + compare); the new-world result for migrated constructs must match the + old-world result. +- **3.0 mass-deletion list**: everything in the left column of §2, the guard itself, and this + document. + +## 7. Status log + +- 2026-06-09: `ExprHandler` consolidation (resolveType + specifyTypes live in handlers); + guard commit; fiber delivery of `ExpressionResult` (`9cb1d353f0`); `Scalar`/`Variable`/ + `Assign` typeCallbacks; `echo '1';` green under guard; FNSR=0 parity restored (`891bad60ff`). +- 2026-06-10: feasibility research (this document); decision: `NarrowingResult` envelope, + `ResultAwareScope` adapter, tiered original-type resolution in `applySpecifiedTypes`. From d6e3cb1257ddae8667901a3ce9f76656eb62a875 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 09:48:37 +0200 Subject: [PATCH 04/50] Migrate ScalarHandler, AssignHandler and FuncCallHandler to the new world The new world is cut away from the old: typeCallback/specifyTypesCallback carry copied-and-adjusted code, never delegating to resolveType()/ specifyTypes() (which are deleted in 3.0). ResultAwareScope is used only at the sanctioned boundaries: extension invocations and ParametersAcceptorSelector (+ TypeSpecifier conditional-return/assert helpers until ported). - ResultAwareScope: non-suspending adapter for code that receives a Scope mid-analysis. getType() tiers: ExpressionTypeResolverExtensions -> scope- tracked holder -> known child ExpressionResults -> inline re-processing of the (possibly synthetic) expression -> guarded legacy bridge. Derivation-safe (pushInFunctionCall/popInFunctionCall carry the adapter context, mirroring FiberScope); native variant via doNotTreatPhpDocTypesAsCertain override. - TypeSpecifier::specifyTypesInCondition head-checks: ResultAwareScope recursion stays in the new world; FiberScope (rules, e.g. ImpossibleCheckTypeHelper) suspends for the ExpressionResult. - FiberScope: getExpressionResult() extracted; doNotTreatPhpDocTypesAsCertain stays fiber-aware. - ScalarHandler: specifyTypesCallback via DefaultNarrowingHelper (new-world copy of default truthy/falsey narrowing using the expression's own type). - AssignHandler: the processAssignVar callback result carries the assigned value's type (hasTypeCallback() contract, AssignOp wraps with expr only and bridges); nested assigns flow through result delegation (no unwrapAssign on the type path, no storage lookups); specifyTypesForAssign covers null context (RHS result narrowing minus the assigned var) and variable targets (default narrowing with the RHS type); conditional-expression holders gated old-world-only with a TODO. - FuncCallHandler: resolveTypeViaResults/specifyTypesViaResults new-world copies; dynamic name uses the name ExpressionResult; call_user_func/clone synthetics processed inline; getFunctionThrowPoint takes a lazy return-type callback and gives throw-type extensions the adapter; processArgs takes the callable-arg type from the result. - NewWorld::isEnabled() transitional switch; NewWorldTypeInferenceTest (temporary, deleted when the suite is green under the guard) - 13 assertions green in both worlds; FNSR=0 parity verified on stress files. Co-Authored-By: Claude Opus 4.8 (1M context) --- NEW_WORLD.md | 26 +- src/Analyser/DirectInternalScopeFactory.php | 24 ++ src/Analyser/ExprHandler/AssignHandler.php | 101 +++++-- src/Analyser/ExprHandler/AssignOpHandler.php | 26 +- src/Analyser/ExprHandler/FuncCallHandler.php | 258 +++++++++++++++++- .../Helper/DefaultNarrowingHelper.php | 61 +++++ src/Analyser/ExprHandler/ScalarHandler.php | 7 +- src/Analyser/ExpressionResult.php | 10 + src/Analyser/Fiber/FiberScope.php | 45 +-- src/Analyser/InternalScopeFactory.php | 2 + src/Analyser/LazyInternalScopeFactory.php | 8 + src/Analyser/MutatingScope.php | 34 ++- src/Analyser/NewWorld.php | 8 + src/Analyser/NodeScopeResolver.php | 16 +- src/Analyser/ResultAwareScope.php | 233 ++++++++++++++++ src/Analyser/TypeSpecifier.php | 20 +- .../Analyser/NewWorldTypeInferenceTest.php | 53 ++++ tests/PHPStan/Analyser/data/new-world.php | 50 ++++ 18 files changed, 918 insertions(+), 64 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php create mode 100644 src/Analyser/ResultAwareScope.php create mode 100644 tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php create mode 100644 tests/PHPStan/Analyser/data/new-world.php diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 8998463549..1e02b161f7 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -102,7 +102,14 @@ deliverable at every point along the way. `normalize()`, and supplies dim/var types for the `ArrayDimFetch` parent-update — all via `ExpressionResult::getType()`, honoring §3.3. Extension-produced `SpecifiedTypes` flow through with an empty map. -8. **Two adapters, by execution context**: +8. **The new world is cut away from the old world.** Callbacks contain *copied + and adjusted* code — they never delegate to `resolveType`/`specifyTypes` + (those must be deletable in 3.0). Duplication between the worlds is accepted. + `ResultAwareScope` is used **only at two sanctioned boundaries**: invoking + extensions, and `ParametersAcceptorSelector` (+ the TypeSpecifier + conditional-return/assert helpers until they are ported). It is *not* a + general bridge for running old-world handler bodies. +9. **Two adapters, by execution context**: - **`FiberScope`** (exists): for *rule* node-callbacks, which run before the expression is processed. `getType()` suspends the fiber; the engine resumes it with the `ExpressionResult` at the end of `processExprNode`. Synthetic exprs are processed on @@ -227,3 +234,20 @@ Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive `Assign` typeCallbacks; `echo '1';` green under guard; FNSR=0 parity restored (`891bad60ff`). - 2026-06-10: feasibility research (this document); decision: `NarrowingResult` envelope, `ResultAwareScope` adapter, tiered original-type resolution in `applySpecifiedTypes`. +- 2026-06-10 (later): first three handlers fully migrated — `ScalarHandler`, + `AssignHandler` (value result threaded through the `processAssignVar` callback; + `hasTypeCallback()` contract; conditional-expression holders gated old-world-only + with a TODO), `FuncCallHandler` (`resolveTypeViaResults`/`specifyTypesViaResults` + copies; return-type + type-specifying extensions and `selectFromArgs` through + `ResultAwareScope`; throw-point never-detection via lazy return-type callback). + Supporting infra: `ResultAwareScope` (tiers: extensions → tracked → known results → + inline re-process → guarded bridge; derivation-safe via `pushInFunctionCall` + overrides), `NewWorld::isEnabled()`, `DefaultNarrowingHelper` (new-world copy of + default truthy/falsey narrowing), `TypeSpecifier::specifyTypesInCondition` + head-check for `ResultAwareScope` (recursion stays new-world) and `FiberScope` + (rules suspend for the result — un-guards `ImpossibleCheckTypeHelper`), + `FiberScope::doNotTreatPhpDocTypesAsCertain` fiber-safety, `processArgs` + callable-arg type from the result. **`NewWorldTypeInferenceTest` added** + (temporary; delete when the whole suite is green under the guard): 13 assertions + over scalars, assigns (incl. nested), params, and function calls (signature, + constant-folding extensions, nested calls) — green in both worlds. diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 533d37c5f9..c16b83aff6 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -38,6 +38,7 @@ public function __construct( private $nodeCallback, private ConstantResolver $constantResolver, private bool $fiber = false, + private bool $resultAware = false, ) { } @@ -64,6 +65,8 @@ public function create( $className = MutatingScope::class; if ($this->fiber) { $className = FiberScope::class; + } elseif ($this->resultAware) { + $className = ResultAwareScope::class; } return new $className( @@ -120,6 +123,27 @@ public function toFiberFactory(): InternalScopeFactory ); } + public function toResultAwareFactory(): InternalScopeFactory + { + return new self( + $this->container, + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->expressionTypeResolverExtensionRegistryProvider, + $this->exprPrinter, + $this->typeSpecifier, + $this->propertyReflectionFinder, + $this->parser, + $this->phpVersion, + $this->attributeReflectionFactory, + $this->configPhpVersion, + $this->nodeCallback, + $this->constantResolver, + false, + true, + ); + } + public function toMutatingFactory(): InternalScopeFactory { return new self( diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 862be350d4..c6abff8c30 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -28,9 +28,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; @@ -97,6 +99,7 @@ public function __construct( private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -291,6 +294,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $assignedExprResult = null; $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -300,7 +304,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$assignedExprResult): ExpressionResult { $impurePoints = []; if ($expr instanceof AssignRef) { $referencedExpr = $expr->expr; @@ -330,6 +334,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $assignedExprResult = $result; $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -340,7 +345,20 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $scope = $scope->exitExpressionAssign($expr->expr); } - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + // the value of an assignment is its assigned value — delegate to its result + return new ExpressionResult( + $scope, + $hasYield, + $isAlwaysTerminating, + $throwPoints, + $impurePoints, + expr: $expr->expr, + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $result->getTypeForScope($s), + specifyTypesCallback: $result->hasSpecifiedTypesCallback() + ? static fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $result->getSpecifiedTypes($s, $ctx) + : null, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, + ); }, true, ); @@ -391,18 +409,63 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, - typeCallback: function (Expr $e, MutatingScope $s) use ($storage): Type { - /** @var Assign|AssignRef $e */ - $assignedExprResult = $storage->findResult($e->expr); + typeCallback: static function (Expr $e, MutatingScope $s) use (&$assignedExprResult): Type { if ($assignedExprResult === null) { - throw new ShouldNotHappenException(); + // assignment shape whose value was not processed through the callback; + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return $s->getType($e); } + return $assignedExprResult->getTypeForScope($s); }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use (&$assignedExprResult): SpecifiedTypes { + /** @var Assign|AssignRef $e */ + return $this->specifyTypesForAssign($e, $s, $ctx, $assignedExprResult); + }, expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } + /** + * New-world narrowing for assignments. The RHS-FuncCall special cases + * (array_key_first/array_search/... conditional holders) and non-Variable + * assignment targets still go through the guarded legacy path — they will + * be ported together with the handlers they depend on. + */ + private function specifyTypesForAssign(Assign|AssignRef $expr, MutatingScope $scope, TypeSpecifierContext $context, ?ExpressionResult $assignedExprResult): SpecifiedTypes + { + if ( + $expr instanceof AssignRef + || $assignedExprResult === null + || ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_search', 'array_find_key', 'array_rand'], true) + ) + ) { + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + + if ($context->null()) { + if (!$assignedExprResult->hasSpecifiedTypesCallback()) { + // guarded legacy bridge for not-yet-migrated assigned values + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + + $specifiedTypes = $assignedExprResult->getSpecifiedTypes($scope->exitFirstLevelStatements(), $context)->setRootExpr($expr); + + return $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); + } + + if ($expr->var instanceof Variable && is_string($expr->var->name)) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $assignedExprResult->getTypeForScope($scope), $context)->setRootExpr($expr); + } + + // guarded legacy bridge + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback @@ -440,17 +503,17 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - // A plain Assign's value is processed via processExprNode and stored, - // so its (possibly migrated) type comes from the ExpressionResult. - // AssignOp and other not-separately-processed values fall back to the - // legacy scope type (works under PHPSTAN_FNSR=0, guarded under FNSR=1). - $assignedExprResult = $storage->findResult($assignedExpr); - $type = $assignedExprResult !== null - ? $assignedExprResult->getType() + // The callback result's typeCallback (when present) resolves the type of + // the assigned value — AssignHandler delegates it to the value's own + // ExpressionResult. Callers that don't supply one (AssignOp, virtual + // assigns) fall back to the guarded legacy scope type (PHPSTAN_FNSR=0). + $type = $result->hasTypeCallback() + ? $result->getType() : $scopeBeforeAssignEval->getType($assignedExpr); + // TODO(new-world): port conditional-expression holders to ExpressionResult-based narrowing $conditionalExpressions = []; - if ($assignedExpr instanceof Ternary) { + if (!NewWorld::isEnabled() && $assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { $if = $assignedExpr->cond; @@ -474,7 +537,7 @@ public function processAssignVar( } } - if ($assignedExpr instanceof Match_) { + if (!NewWorld::isEnabled() && $assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), @@ -482,7 +545,7 @@ public function processAssignVar( } $truthyType = TypeCombinator::removeFalsey($type); - if ($truthyType !== $type) { + if (!NewWorld::isEnabled() && $truthyType !== $type) { $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); @@ -493,7 +556,7 @@ public function processAssignVar( $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } - foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { + foreach (NewWorld::isEnabled() ? [] : [null, false, 0, 0.0, '', '0', []] as $falseyScalar) { $falseyType = ConstantTypeHelper::getTypeFromValue($falseyScalar); $withoutFalseyType = TypeCombinator::remove($type, $falseyType); if ( @@ -529,8 +592,8 @@ public function processAssignVar( } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $assignedNativeType = $assignedExprResult !== null - ? $assignedExprResult->getNativeType() + $assignedNativeType = $result->hasTypeCallback() + ? $result->getNativeType() : $scopeBeforeAssignEval->getNativeType($assignedExpr); $scope = $scope->assignVariable($var->name, $type, $assignedNativeType, TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 83a6fec28d..bf28892751 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -53,6 +53,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 { + $rhsExprResult = null; $assignResult = $this->assignHandler->processAssignVar( $nodeScopeResolver, $scope, @@ -62,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$rhsExprResult): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { $scope = $scope->filterByFalseyValue( @@ -71,6 +72,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $rhsExprResult = $exprResult; if ($expr instanceof Expr\AssignOp\Coalesce) { $nodeScopeResolver->storeBeforeScope($storage, $expr, $originalScope); $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); @@ -80,10 +82,20 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $isAlwaysTerminating, $exprResult->getThrowPoints(), $exprResult->getImpurePoints(), + expr: $expr, ); } - return $exprResult; + // the assigned value of an AssignOp is the op result, not the right side — + // wrap so processAssignVar falls back to the (guarded) legacy type of $expr + return new ExpressionResult( + $exprResult->getScope(), + $exprResult->hasYield(), + $exprResult->isAlwaysTerminating(), + $exprResult->getThrowPoints(), + $exprResult->getImpurePoints(), + expr: $expr, + ); }, $expr instanceof Expr\AssignOp\Coalesce, ); @@ -94,17 +106,17 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $throwPoints = $assignResult->getThrowPoints(); $impurePoints = $assignResult->getImpurePoints(); if ( - ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) + && $rhsExprResult !== null + && !$rhsExprResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - $exprResult = $storage->findResult($expr->expr); - if ($exprResult === null) { + if ($rhsExprResult === null) { throw new ShouldNotHappenException(); } - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $rhsExprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d28a3225de..ee83e2f97f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -32,6 +33,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Node\ClosureReturnStatementsNode; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\PossiblyImpureCallExpr; @@ -44,6 +46,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasPropertyType; @@ -89,6 +92,9 @@ final class FuncCallHandler implements ExprHandler public function __construct( private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] @@ -111,18 +117,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $nameResult = null; if ($expr->name instanceof Expr) { - $nameType = $scope->getType($expr->name); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $nameType = $nameResult->getType(); if (!$nameType->isCallable()->no()) { + $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $adapterScope, $expr->getArgs(), - $nameType->getCallableParametersAcceptors($scope), + $nameType->getCallableParametersAcceptors($adapterScope), null, ); } - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $nameResult->getScope(); $throwPoints = $nameResult->getThrowPoints(); $impurePoints = $nameResult->getImpurePoints(); @@ -146,7 +154,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage), $expr->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants(), @@ -318,7 +326,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($functionReflection !== null) { - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $normalizedExprForThrowPoint = $normalizedExpr; + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -571,6 +580,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $storage, $nameResult): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + }; + return new ExpressionResult( $scope, hasYield: $hasYield, @@ -579,15 +596,238 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $typeCallback): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, static fn (): Type => $typeCallback($e, $s), $nodeScopeResolver, $stmt, $storage); + }, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, + ); + } + + /** + * New-world copy of resolveType(): resolves the call's return type from + * already-known ExpressionResults. ResultAwareScope is used only at the + * sanctioned boundaries — extension invocations and ParametersAcceptorSelector. + */ + private function resolveTypeViaResults( + FuncCall $expr, + MutatingScope $scope, + ?ExpressionResult $nameResult, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + ): Type + { + $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); + + if ($expr->name instanceof Expr) { + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + + $calledOnType = $nameResult->getTypeForScope($scope); + if ($calledOnType->isCallable()->no()) { + return new ErrorType(); + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $adapterScope, + $expr->getArgs(), + $calledOnType->getCallableParametersAcceptors($adapterScope), + null, + ); + + $functionName = null; + if ($expr->name instanceof String_) { + /** @var non-empty-string $name */ + $name = $expr->name->value; + $functionName = new Name($name); + } elseif ( + $expr->name instanceof FuncCall + && $expr->name->name instanceof Name + && $expr->name->isFirstClassCallable() + ) { + $functionName = $expr->name->name; + } + + $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); + if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $scope)) { + $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); + $resolvedType = $this->getDynamicFunctionReturnType($adapterScope, $normalizedNode, $functionReflection); + if ($resolvedType !== null) { + return $resolvedType; + } + } + + return $parametersAcceptor->getReturnType(); + } + + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return new ErrorType(); + } + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + if ($scope->nativeTypesPromoted) { + return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); + } + + if ($functionReflection->getName() === 'call_user_func') { + $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $adapterScope); + if ($result !== null) { + [, $innerFuncCall] = $result; + + return $nodeScopeResolver->processExprNode($stmt, $innerFuncCall, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + } + } + + if ($functionReflection->getName() === 'call_user_func_array') { + $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $adapterScope); + if ($result !== null) { + [, $innerFuncCall] = $result; + + return $nodeScopeResolver->processExprNode($stmt, $innerFuncCall, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + } + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $adapterScope, + $expr->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), ); + $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); + if ($normalizedNode !== null) { + if ($functionReflection->getName() === 'clone' && count($normalizedNode->getArgs()) > 0) { + $cloneType = $nodeScopeResolver->processExprNode($stmt, new Expr\Clone_($normalizedNode->getArgs()[0]->value), $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + if (count($normalizedNode->getArgs()) === 2) { + $propertiesType = $adapterScope->getType($normalizedNode->getArgs()[1]->value); + if ($propertiesType->isConstantArray()->yes()) { + $constantArrays = $propertiesType->getConstantArrays(); + if (count($constantArrays) === 1) { + $accessories = []; + foreach ($constantArrays[0]->getKeyTypes() as $keyType) { + $constantKeyTypes = $keyType->getConstantScalarValues(); + if (count($constantKeyTypes) !== 1) { + return $cloneType; + } + $accessories[] = new HasPropertyType((string) $constantKeyTypes[0]); + } + if (count($accessories) > 0 && count($accessories) <= 16) { + return TypeCombinator::intersect($cloneType, ...$accessories); + } + } + } + } + + return $cloneType; + } + $resolvedType = $this->getDynamicFunctionReturnType($adapterScope, $normalizedNode, $functionReflection); + if ($resolvedType !== null) { + return $resolvedType; + } + } + + return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); + } + + /** + * New-world copy of specifyTypes(). Conditional-return-type and assert + * narrowing still delegate to TypeSpecifier helpers (with the adapter) — + * to be ported before the old world is deleted. + * + * @param callable(): Type $ownTypeCallback + */ + private function specifyTypesViaResults( + FuncCall $expr, + MutatingScope $scope, + TypeSpecifierContext $context, + ?ExpressionResult $nameResult, + callable $ownTypeCallback, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + ): SpecifiedTypes + { + if (!$expr->name instanceof Name) { + // dynamic-name calls: guarded legacy bridge for now (PHPSTAN_FNSR=0) + return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); + } + + $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); + + if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { + // lazy create parametersAcceptor, as creation can be expensive + $parametersAcceptor = null; + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $normalizedExpr = $expr; + $args = $expr->getArgs(); + if (count($args) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($adapterScope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + $normalizedExpr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr) ?? $expr; + } + + foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { + continue; + } + + return $extension->specifyTypes($functionReflection, $normalizedExpr, $adapterScope, $context); + } + + if (count($args) > 0) { + // TODO(new-world): port conditional-return-type narrowing off TypeSpecifier + $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $adapterScope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + $assertions = $functionReflection->getAsserts(); + if ($assertions->getAll() !== []) { + $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($adapterScope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + // TODO(new-world): port assert narrowing off TypeSpecifier + $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $adapterScope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + // default narrowing with the purity gate mirroring TypeSpecifier::createForExpr() + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no())) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $ownTypeCallback(), $context); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * @param callable(): Type $returnTypeCallback + */ private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, FuncCall $normalizedFuncCall, MutatingScope $scope, ExpressionContext $context, + callable $returnTypeCallback, + MutatingScope $extensionScope, ): ?InternalThrowPoint { foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicFunctionThrowTypeExtensions() as $extension) { @@ -595,7 +835,7 @@ private function getFunctionThrowPoint( continue; } - $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $scope); + $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $extensionScope); if ($throwType === null) { return null; } @@ -605,7 +845,7 @@ private function getFunctionThrowPoint( $throwType = $functionReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedFuncCall); + $returnType = $returnTypeCallback(); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -633,7 +873,7 @@ private function getFunctionThrowPoint( || $requiredParameters > 0 || count($normalizedFuncCall->getArgs()) > 0 ) { - $functionReturnedType = $scope->getType($normalizedFuncCall); + $functionReturnedType = $returnTypeCallback(); if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($functionReturnedType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedFuncCall); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php new file mode 100644 index 0000000000..32bff596de --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,61 @@ +null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + // mirrors TypeSpecifier::createForExpr() in createFalse() context + $containsNull = !TypeCombinator::containsNull($removedType) && !$exprType->isNull()->no(); + + $originalExpr = $expr; + if (!$containsNull) { + $expr = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($expr); + } + + $sureNotTypes = [ + $this->exprPrinter->printExpr($expr) => [$expr, $removedType], + ]; + if ($expr !== $originalExpr) { + $sureNotTypes[$this->exprPrinter->printExpr($originalExpr)] = [$originalExpr, $removedType]; + } + + return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($originalExpr); + } + +} diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 98c2137ed4..44fdf3a0ca 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -32,6 +33,7 @@ final class ScalarHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -43,6 +45,8 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $typeCallback = fn (Expr $e, MutatingScope $s): Type => $this->initializerExprTypeResolver->getType($e, InitializerExprContext::fromScope($s)); + return new ExpressionResult( $scope, hasYield: false, @@ -50,7 +54,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: [], impurePoints: [], expr: $expr, - typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->initializerExprTypeResolver->getType($e, InitializerExprContext::fromScope($s)), + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 6f5db5c71c..7e77ba505e 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -148,6 +148,16 @@ public function getTypeForScope(MutatingScope $scope): Type return $this->getType(); } + public function hasTypeCallback(): bool + { + return $this->typeCallback !== null && $this->expr !== null; + } + + public function hasSpecifiedTypesCallback(): bool + { + return $this->specifyTypesCallback !== null && $this->expr !== null; + } + public function getSpecifiedTypes(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes { if ($this->expr === null || $this->specifyTypesCallback === null) { diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 2525decb91..87b21b24ff 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -10,6 +10,7 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use function array_pop; @@ -55,15 +56,37 @@ public function toMutatingScope(): MutatingScope ); } - /** @api */ - public function getType(Expr $node): Type + /** + * Suspends until the engine can deliver the ExpressionResult for the given + * expression — immediately when already processed, after its processExprNode + * finishes when not, or by processing it on demand when it is synthetic. + * + * @internal + */ + public function getExpressionResult(Expr $expr): ExpressionResult { /** @var ExpressionResult $result */ $result = Fiber::suspend( - new ExpressionResultForExprRequest($node, $this), + new ExpressionResultForExprRequest($expr, $this), ); - return $result->getTypeForScope($this); + return $result; + } + + public function doNotTreatPhpDocTypesAsCertain(): Scope + { + $scope = parent::doNotTreatPhpDocTypesAsCertain(); + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $scope->toFiberScope(); + } + + /** @api */ + public function getType(Expr $node): Type + { + return $this->getExpressionResult($node)->getTypeForScope($this); } public function getScopeType(Expr $expr): Type @@ -79,23 +102,13 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - /** @var ExpressionResult $result */ - $result = Fiber::suspend( - new ExpressionResultForExprRequest($expr, $this), - ); - - return $result->getNativeType(); + return $this->getExpressionResult($expr)->getNativeType(); } public function getKeepVoidType(Expr $node): Type { - /** @var ExpressionResult $result */ - $result = Fiber::suspend( - new ExpressionResultForExprRequest($node, $this), - ); - // keepVoid is a one-off we will solve separately; fall back to the regular type for now. - return $result->getTypeForScope($this); + return $this->getExpressionResult($node)->getTypeForScope($this); } public function filterByTruthyValue(Expr $expr): self diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php index 3f32562ca4..3bb389d073 100644 --- a/src/Analyser/InternalScopeFactory.php +++ b/src/Analyser/InternalScopeFactory.php @@ -43,4 +43,6 @@ public function toFiberFactory(): self; public function toMutatingFactory(): self; + public function toResultAwareFactory(): self; + } diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index 30bad59a9a..16c8cd69d9 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -52,6 +52,7 @@ public function __construct( private Container $container, private $nodeCallback, private bool $fiber = false, + private bool $resultAware = false, ) { $this->phpVersion = $this->container->getParameter('phpVersion'); @@ -80,6 +81,8 @@ public function create( $className = MutatingScope::class; if ($this->fiber) { $className = FiberScope::class; + } elseif ($this->resultAware) { + $className = ResultAwareScope::class; } $this->reflectionProvider ??= $this->container->getByType(ReflectionProvider::class); @@ -138,4 +141,9 @@ public function toMutatingFactory(): InternalScopeFactory return new self($this->container, $this->nodeCallback, false); } + public function toResultAwareFactory(): InternalScopeFactory + { + return new self($this->container, $this->nodeCallback, false, true); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1b07b3dc3a..b11ee53567 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -181,7 +181,7 @@ public function __construct( protected InternalScopeFactory $scopeFactory, private ReflectionProvider $reflectionProvider, private InitializerExprTypeResolver $initializerExprTypeResolver, - private ExpressionTypeResolverExtensionRegistry $expressionTypeResolverExtensionRegistry, + protected ExpressionTypeResolverExtensionRegistry $expressionTypeResolverExtensionRegistry, private ExprPrinter $exprPrinter, private TypeSpecifier $typeSpecifier, private PropertyReflectionFinder $propertyReflectionFinder, @@ -251,6 +251,38 @@ public function toMutatingScope(): self return $this; } + /** + * @param array $exprResults + */ + public function toResultAwareScope(array $exprResults, NodeScopeResolver $nodeScopeResolver, Node\Stmt $stmt, ExpressionResultStorage $storage): ResultAwareScope + { + $scope = $this->scopeFactory->toResultAwareFactory()->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + if (!$scope instanceof ResultAwareScope) { + throw new ShouldNotHappenException(); + } + + $scope->initializeResultAware($this, $exprResults, $nodeScopeResolver, $stmt, $storage); + + return $scope; + } + /** @api */ public function getFile(): string { diff --git a/src/Analyser/NewWorld.php b/src/Analyser/NewWorld.php index d555f78874..c97d92bda5 100644 --- a/src/Analyser/NewWorld.php +++ b/src/Analyser/NewWorld.php @@ -2,6 +2,9 @@ namespace PHPStan\Analyser; +use function getenv; +use const PHP_VERSION_ID; + /** * Transitional switch between the old world (multi-pass type resolution via * MutatingScope::resolveType + TypeSpecifier, PHP < 8.1 or PHPSTAN_FNSR=0) @@ -12,6 +15,11 @@ final class NewWorld { + public static function isEnabled(): bool + { + return PHP_VERSION_ID >= 80100 && getenv('PHPSTAN_FNSR') !== '0'; + } + /** * The single switch for the guard exceptions in MutatingScope::getType()/ * getNativeType()/getKeepVoidType() and TypeSpecifier::specifyTypesInCondition(). diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a408ce5470..0d14269619 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1148,11 +1148,15 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( - $scope, - $stmt->expr, - TypeSpecifierContext::createNull(), - )); + if ($result->hasSpecifiedTypesCallback()) { + $scope = $scope->filterBySpecifiedTypes($result->getSpecifiedTypes($scope, TypeSpecifierContext::createNull())); + } else { + $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $scope, + $stmt->expr, + TypeSpecifierContext::createNull(), + )); + } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -3716,12 +3720,12 @@ public function processArgs( } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); } else { - $exprType = $scope->getType($arg->value); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); + $exprType = $exprResult->getType(); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); diff --git a/src/Analyser/ResultAwareScope.php b/src/Analyser/ResultAwareScope.php new file mode 100644 index 0000000000..7e7f62439b --- /dev/null +++ b/src/Analyser/ResultAwareScope.php @@ -0,0 +1,233 @@ + */ + private array $exprResults = []; + + private ?MutatingScope $plainScope = null; + + private ?NodeScopeResolver $nodeScopeResolver = null; + + private ?Stmt $stmt = null; + + private ?ExpressionResultStorage $resultStorage = null; + + private ?self $promotedScope = null; + + /** + * @param array $exprResults + * + * @internal + */ + public function initializeResultAware( + MutatingScope $plainScope, + array $exprResults, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $resultStorage, + ): void + { + $this->plainScope = $plainScope; + $this->exprResults = $exprResults; + $this->nodeScopeResolver = $nodeScopeResolver; + $this->stmt = $stmt; + $this->resultStorage = $resultStorage; + } + + public function toResultAwareScope(array $exprResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage): self + { + if ($this->plainScope === null) { + throw new ShouldNotHappenException(); + } + + // don't wrap an adapter in an adapter — merge the known results instead + return $this->plainScope->toResultAwareScope($exprResults + $this->exprResults, $nodeScopeResolver, $stmt, $storage); + } + + /** @api */ + public function getType(Expr $node): Type + { + return TypeUtils::resolveLateResolvableTypes($this->resolveTypeViaResults($node)); + } + + /** @api */ + public function getNativeType(Expr $expr): Type + { + $scope = $this->doNotTreatPhpDocTypesAsCertain(); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + return $scope->getType($expr); + } + + public function getKeepVoidType(Expr $node): Type + { + // keepVoid is a one-off solved separately; fall back to the regular type for now + return $this->getType($node); + } + + public function doNotTreatPhpDocTypesAsCertain(): Scope + { + if ($this->nativeTypesPromoted) { + return $this; + } + + if ($this->promotedScope !== null) { + return $this->promotedScope; + } + + if ($this->plainScope === null || $this->nodeScopeResolver === null || $this->stmt === null || $this->resultStorage === null) { + throw new ShouldNotHappenException(); + } + + $promotedPlainScope = $this->plainScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedPlainScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $this->promotedScope = $promotedPlainScope->toResultAwareScope( + $this->exprResults, + $this->nodeScopeResolver, + $this->stmt, + $this->resultStorage, + ); + } + + /** + * Resolves SpecifiedTypes for the given expr through ExpressionResults — + * called from the head of TypeSpecifier::specifyTypesInCondition() so that + * old-world narrowing code recursing with this scope stays in the new world. + * + * @internal + */ + public function specifyTypesViaResults(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + $key = $this->getNodeKey($expr); + if (array_key_exists($key, $this->exprResults)) { + return $this->exprResults[$key]->getSpecifiedTypes($this, $context); + } + + return $this->processSynthetic($expr)->getSpecifiedTypes($this, $context); + } + + /** + * Scope-deriving methods create new instances through the scope factory — + * carry the adapter context over, mirroring FiberScope. + * + * @param FunctionReflection|MethodReflection|null $reflection + */ + public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, bool $rememberTypes): self + { + $scope = parent::pushInFunctionCall($reflection, $parameter, $rememberTypes); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + $scope->copyResultAwareContextFrom($this); + + return $scope; + } + + public function popInFunctionCall(): self + { + $scope = parent::popInFunctionCall(); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + $scope->copyResultAwareContextFrom($this); + + return $scope; + } + + private function copyResultAwareContextFrom(self $other): void + { + $this->plainScope = $other->plainScope; + $this->exprResults = $other->exprResults; + $this->nodeScopeResolver = $other->nodeScopeResolver; + $this->stmt = $other->stmt; + $this->resultStorage = $other->resultStorage; + } + + private function resolveTypeViaResults(Expr $node): Type + { + foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { + $type = $extension->getType($node, $this); + if ($type !== null) { + return $type; + } + } + + if ( + !$node instanceof Expr\Variable + && !$node instanceof Expr\Closure + && !$node instanceof Expr\ArrowFunction + && $this->hasExpressionType($node)->yes() + ) { + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + + $key = $this->getNodeKey($node); + if (array_key_exists($key, $this->exprResults)) { + return $this->exprResults[$key]->getTypeForScope($this); + } + + if ($this->plainScope === null) { + // derived through a scope-mutation path that did not carry the adapter + // context — degrade to the guarded legacy bridge (PHPSTAN_FNSR=0) + return parent::getType($node); + } + + return $this->processSynthetic($node)->getTypeForScope($this); + } + + private function processSynthetic(Expr $expr): ExpressionResult + { + if ($this->plainScope === null || $this->nodeScopeResolver === null || $this->stmt === null || $this->resultStorage === null) { + throw new ShouldNotHappenException('ResultAwareScope is missing its adapter context.'); + } + + return $this->nodeScopeResolver->processExprNode( + $this->stmt, + $expr, + $this->plainScope, + $this->resultStorage->duplicate(), + new NoopNodeCallback(), + ExpressionContext::createDeep(), + ); + } + +} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 1af22c0a32..2e6e3a6699 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -11,6 +11,7 @@ use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -87,15 +88,26 @@ public function specifyTypesInCondition( TypeSpecifierContext $context, ): SpecifiedTypes { + if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if ($scope instanceof ResultAwareScope) { + // new world: old-world narrowing code recursing with the adapter + // stays in the new world — resolved through ExpressionResults + return $scope->specifyTypesViaResults($expr, $context); + } + + if ($scope instanceof FiberScope) { + // new world: rules asking for narrowing suspend for the ExpressionResult + return $scope->getExpressionResult($expr)->getSpecifiedTypes($scope->toMutatingScope(), $context); + } + $enableFnsr = getenv('PHPSTAN_FNSR'); if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { throw new ShouldNotHappenException('TypeSpecifier should not be used here. Ask ExpressionResult for SpecifiedTypes instead.'); } - if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - /** @var ExprHandler $exprHandler */ foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($expr)) { diff --git a/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php new file mode 100644 index 0000000000..dbc1badc4c --- /dev/null +++ b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php @@ -0,0 +1,53 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return []; + } + +} diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php new file mode 100644 index 0000000000..4b3872b40c --- /dev/null +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -0,0 +1,50 @@ +', $len); + + $cnt = strlen('abc'); + assertType('3', $cnt); + + $abs = abs($i); + assertType('int<0, max>', $abs); + + $abs2 = abs(7); + assertType('7', $abs2); + + $nested = strlen(strtoupper($s)); + assertType('int<0, max>', $nested); + + $pi = pi(); + assertType('float', $pi); + } + +} From 699b3c99ee13220d76f6a8494568353aeba58255 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 10:35:45 +0200 Subject: [PATCH 05/50] Apply SpecifiedTypes the new-world way and narrow in if/else via ExpressionResults MutatingScope::applySpecifiedTypes() is the new-world replacement for filterBySpecifiedTypes(): original (pre-narrowing) types resolve in tiers (extension registry -> scope-tracked holders -> caller-supplied ExpressionResults -> guarded legacy bridge), the conditional-holder matching tail is shared with the old world via an extracted private method. getTruthyScope()/getFalseyScope() and the per-statement createNull narrowing run on it; VariableHandler and the TypeExpr/NativeTypeExpr virtual handlers migrate; FuncCall conditional-return and asserts narrowing are copied into the handler (*ViaResults) instead of delegating to TypeSpecifier internals. Unmigrated handlers bridge in mixed mode: TypeSpecifier::specifyTypesInCondition head-checks route ResultAwareScope/FiberScope back into the new world when the result carries a specifyTypesCallback and fall through to the guarded dispatcher otherwise. ResultAwareScope is the non-suspending adapter for extensions called mid-analysis (self-seeded results prevent is_int()-family recursion; syntheticsInFlight markers guard against pricing cycles). --- NEW_WORLD.md | 44 +++ phpstan-baseline.neon | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 120 +++++-- src/Analyser/ExprHandler/FuncCallHandler.php | 295 +++++++++++++++++- .../Helper/DefaultNarrowingHelper.php | 2 +- src/Analyser/ExprHandler/VariableHandler.php | 25 +- .../Virtual/NativeTypeExprHandler.php | 23 ++ .../ExprHandler/Virtual/TypeExprHandler.php | 19 ++ src/Analyser/ExpressionResult.php | 29 +- src/Analyser/ExpressionResultStorage.php | 28 +- src/Analyser/Fiber/FiberNodeScopeResolver.php | 14 +- src/Analyser/Fiber/FiberScope.php | 16 - src/Analyser/MutatingScope.php | 136 ++++++++ src/Analyser/NodeScopeResolver.php | 23 +- src/Analyser/ResultAwareScope.php | 49 ++- src/Analyser/TypeSpecifier.php | 18 +- tests/PHPStan/Analyser/data/new-world.php | 92 ++++++ 17 files changed, 832 insertions(+), 103 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 1e02b161f7..48f93e21b4 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -210,6 +210,27 @@ Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive synthetic-condition ones (switch `:2023/2049`, foreach `:1462`, while `:1626`) become direct helper calls with results. (`findEarlyTerminatingExpr` already migrated.) +## 5a. Working style + +- **TDD with the guard exceptions active**: when migrating a handler to the new + world, always start by flipping `NewWorld::disableOldWorld()` to `return true;` + (the committed state is `return false;` — mixed mode, everything green). Then + drive the work with `NewWorldTypeInferenceTest`: + 1. add probes for the handler's constructs to `data/new-world.php` (or rely on + bridge probes already there) and run the test — **red**, the guard message + names the unmigrated handler; + 2. implement `typeCallback`/`specifyTypesCallback` until the test progresses + past those constructs — the meter walks the data file in order, naming the + next unmigrated handler ("fix, rerun, next"); + 3. flip `disableOldWorld()` back to `return false;` and run the mixed-mode + scoreboard (nsrt `NodeScopeResolverTest` + `make phpstan`) to verify + whole-suite impact — `false` is also what gets committed. + Each condition and branch of new-world code gets a probing assertType test. +- **No TODO markers in new-world code** — deferred functionality is implemented + immediately. Where something genuinely depends on a not-yet-migrated handler, + the code states that dependency as a fact (and bridges or skips), it doesn't + promise future work. + ## 6. Migration mechanics - **Exercisers**: tiny files analysed with `bin/phpstan analyse -l 8 test.php --debug` under @@ -251,3 +272,26 @@ Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive (temporary; delete when the whole suite is green under the guard): 13 assertions over scalars, assigns (incl. nested), params, and function calls (signature, constant-folding extensions, nested calls) — green in both worlds. +- 2026-06-10 (TDD leg): **`MutatingScope::applySpecifiedTypes`** lands — the new-world + apply side. Original (pre-narrowing) types resolved in tiers (extension registry → + scope-tracked holders → caller-supplied ExpressionResults → guarded bridge); the + conditional-holder matching tail is shared with `filterBySpecifiedTypes` via an + extracted private method. `getTruthyScope`/`getFalseyScope` and the per-statement + createNull narrowing run on it. `VariableHandler` gets its own copied typeCallback + + default-narrowing specify callback; `TypeExpr`/`NativeTypeExpr` virtual handlers + migrate (their type is the wrapped type); synthetic fiber requests are processed on + the plain scope (a FiberScope would suspend from within — found via an infinite + loop in the asserts flow). FuncCall conditional-return + asserts narrowing are + **copied** into the handler (`*ViaResults`), no longer delegating to the + TypeSpecifier internals; the `@api` `create()`/`specifyTypesInCondition()` (with + adapter) remain the sanctioned entry points. Assign conditional-expression holders + (truthy/falsey projection + falsey-scalar equality holders) are ported with a + per-entry type resolver (assigned result → tracked holders → skip unpriceable + entries, e.g. conditional-return narrowing of inner call arguments); Ternary/Match + holders stay old-world until those handlers migrate. If/elseif condition types and + `processArgs` callable/impure-invalidation types come from ExpressionResults. + `NewWorldTypeInferenceTest`: **33 assertions green in both worlds**, including + `if`/`else` narrowing (`$v = 1; if ($v)` — the original exerciser), assign-in-if, + function asserts (`@phpstan-assert`), conditional return types, holder-driven + narrowing (`$len = strlen($s); if ($len)` → `$s` is `non-empty-string`), and + by-reference assignment. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0ac2ab24ce..2f0cc92a9b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 3 + count: 5 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index c6abff8c30..b18636a344 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -76,6 +76,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use TypeError; +use function array_key_exists; use function array_key_last; use function array_merge; use function array_pop; @@ -373,8 +374,9 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + // the variable was just assigned the referenced value — its type is the value result's + $type = $assignedExprResult !== null ? $assignedExprResult->getType() : $scope->getType($expr->var); + $nativeType = $assignedExprResult !== null ? $assignedExprResult->getNativeType() : $scope->getNativeType($expr->var); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -434,6 +436,11 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto */ private function specifyTypesForAssign(Assign|AssignRef $expr, MutatingScope $scope, TypeSpecifierContext $context, ?ExpressionResult $assignedExprResult): SpecifiedTypes { + if ($expr instanceof AssignRef && $assignedExprResult !== null) { + // the old world treats by-reference assignments with default narrowing + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $assignedExprResult->getTypeForScope($scope), $context); + } + if ( $expr instanceof AssignRef || $assignedExprResult === null @@ -511,7 +518,8 @@ public function processAssignVar( ? $result->getType() : $scopeBeforeAssignEval->getType($assignedExpr); - // TODO(new-world): port conditional-expression holders to ExpressionResult-based narrowing + // Ternary/Match conditional-expression holders need the branch types from + // narrowed scopes — old world only until TernaryHandler/MatchHandler migrate $conditionalExpressions = []; if (!NewWorld::isEnabled() && $assignedExpr instanceof Ternary) { $if = $assignedExpr->if; @@ -544,19 +552,57 @@ public function processAssignVar( ); } + $assignedExprString = $scope->getNodeKey($assignedExpr); + $exprTypeResolver = null; + if (NewWorld::isEnabled()) { + // resolves entry expressions of the projected SpecifiedTypes: + // the assigned expression itself via its ExpressionResult, + // scope-tracked expressions via their holders, anything else + // through the guarded legacy bridge (PHPSTAN_FNSR=0) + $exprTypeResolver = static function (Expr $e, string $eString) use ($assignedExprString, $result, $scope, $nodeScopeResolver, $stmt, $storage): Type { + if ($eString === $assignedExprString && $result->hasTypeCallback()) { + return $result->getType(); + } + if (array_key_exists($eString, $scope->expressionTypes)) { + return TypeUtils::resolveLateResolvableTypes($scope->expressionTypes[$eString]->getType()); + } + + // price sub-expressions of the assigned value (e.g. inner call + // arguments narrowed by conditional return types) through the + // adapter — its tiers and cycle guard fall back to the guarded + // legacy bridge for anything unresolvable (PHPSTAN_FNSR=0) + return $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($e); + }; + } + + $truthySpecifiedTypes = null; $truthyType = TypeCombinator::removeFalsey($type); - if (!NewWorld::isEnabled() && $truthyType !== $type) { - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + if ($truthyType !== $type) { + if (NewWorld::isEnabled() && $result->hasSpecifiedTypesCallback()) { + $truthySpecifiedTypes = $result->getSpecifiedTypes($scope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $result->getSpecifiedTypes($scope, TypeSpecifierContext::createFalsey()); + } else { + // old world, or a not-yet-migrated assigned value — guarded bridge (PHPSTAN_FNSR=0) + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + } + + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $exprTypeResolver); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); } - foreach (NewWorld::isEnabled() ? [] : [null, false, 0, 0.0, '', '0', []] as $falseyScalar) { + // pure function calls (and any non-call value) may be remembered in holders; + // new-world purity is reflected by the truthy SpecifiedTypes being non-empty + $scalarHoldersAllowed = !NewWorld::isEnabled() + || ($truthySpecifiedTypes !== null && ( + !$assignedExpr instanceof FuncCall + || count($truthySpecifiedTypes->getSureTypes()) + count($truthySpecifiedTypes->getSureNotTypes()) > 0 + )); + foreach ($scalarHoldersAllowed ? [null, false, 0, 0.0, '', '0', []] : [] as $falseyScalar) { $falseyType = ConstantTypeHelper::getTypeFromValue($falseyScalar); $withoutFalseyType = TypeCombinator::remove($type, $falseyType); if ( @@ -580,15 +626,25 @@ public function processAssignVar( $astNode = new Node\Expr\Array_($falseyScalar); } - $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); + if (NewWorld::isEnabled()) { + // `$assignedExpr !== ` / `=== ` narrowing, + // constructed directly (equality on a constant scalar removes/pins its type) + $notIdenticalSpecifiedTypes = new SpecifiedTypes(sureNotTypes: [$assignedExprString => [$assignedExpr, $falseyType]]); + } else { + $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); + $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); + } + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); - $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + if (NewWorld::isEnabled()) { + $identicalSpecifiedTypes = new SpecifiedTypes([$assignedExprString => [$assignedExpr, $falseyType]], []); + } else { + $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); + $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); + } + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); @@ -1153,7 +1209,13 @@ private function unwrapAssign(Expr $expr): Expr * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + /** + * @param array $conditionalExpressions + * @param ImpurePoint[] $rhsImpurePoints + * @param (callable(Expr, string): Type)|null $exprTypeResolver + * @return array + */ + private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?callable $exprTypeResolver = null): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1168,7 +1230,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $exprTypeResolver !== null ? $exprTypeResolver($innerExpr, $this->exprPrinter->printExpr($innerExpr)) : $scope->getType($innerExpr), TrinaryLogic::createMaybe(), ); continue; @@ -1176,13 +1238,15 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $exprString = (string) $exprString; + $entryExprType = $exprTypeResolver !== null ? $exprTypeResolver($expr, $exprString) : $scope->getType($expr); + $conditionalExpressions = $this->addConditionalExpressionHolder( $conditionalExpressions, $variableName, $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($entryExprType, $exprType), TrinaryLogic::createYes(), ); } @@ -1195,7 +1259,13 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + /** + * @param array $conditionalExpressions + * @param ImpurePoint[] $rhsImpurePoints + * @param (callable(Expr, string): Type)|null $exprTypeResolver + * @return array + */ + private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?callable $exprTypeResolver = null): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1218,13 +1288,15 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $exprString = (string) $exprString; + $entryExprType = $exprTypeResolver !== null ? $exprTypeResolver($expr, $exprString) : $scope->getType($expr); + $conditionalExpressions = $this->addConditionalExpressionHolder( $conditionalExpressions, $variableName, $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($entryExprType, $exprType), TrinaryLogic::createYes(), ); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index ee83e2f97f..f200e7df89 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -38,6 +38,7 @@ use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; @@ -46,6 +47,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -53,10 +55,13 @@ use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\ClosureType; +use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -70,9 +75,12 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; use Throwable; use function array_filter; +use function array_key_exists; +use function array_last; use function array_map; use function array_merge; use function array_slice; @@ -82,6 +90,8 @@ use function is_string; use function sprintf; use function str_starts_with; +use function strtolower; +use function substr; /** * @implements ExprHandler @@ -122,7 +132,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $nameType = $nameResult->getType(); if (!$nameType->isCallable()->no()) { - $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); + $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $adapterScope, $expr->getArgs(), @@ -154,7 +164,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage), + $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $expr->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants(), @@ -327,7 +337,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($functionReflection !== null) { $normalizedExprForThrowPoint = $normalizedExpr; - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage)); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -609,6 +619,53 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } + /** + * Builds the ResultAwareScope for extension/selector invocations, seeded with + * a self-result so that code asking about the call currently being resolved + * (e.g. is_int()-family return type extensions going through + * ImpossibleCheckTypeHelper) is answered from the call's own narrowing + * instead of re-processing the call — which would recurse forever. + */ + private function createAdapterScope( + FuncCall $expr, + MutatingScope $scope, + ?ExpressionResult $nameResult, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + ): MutatingScope + { + $selfResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($nameResult, $nodeScopeResolver, $stmt, $storage): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nameResult, $nodeScopeResolver, $stmt, $storage): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, fn (): Type => $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage), $nodeScopeResolver, $stmt, $storage); + }, + ); + + $exprResults = [$scope->getNodeKey($expr) => $selfResult]; + if ($nameResult !== null && $expr->name instanceof Expr) { + $exprResults[$scope->getNodeKey($expr->name)] = $nameResult; + } + + return $scope->toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, $storage); + } + /** * New-world copy of resolveType(): resolves the call's return type from * already-known ExpressionResults. ResultAwareScope is used only at the @@ -623,7 +680,7 @@ private function resolveTypeViaResults( ExpressionResultStorage $storage, ): Type { - $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); + $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); if ($expr->name instanceof Expr) { if ($nameResult === null) { @@ -758,7 +815,7 @@ private function specifyTypesViaResults( return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); } - $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage); + $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { // lazy create parametersAcceptor, as creation can be expensive @@ -781,8 +838,7 @@ private function specifyTypesViaResults( } if (count($args) > 0) { - // TODO(new-world): port conditional-return-type narrowing off TypeSpecifier - $specifiedTypes = $this->typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $adapterScope); + $specifiedTypes = $this->specifyTypesFromConditionalReturnTypeViaResults($context, $expr, $parametersAcceptor, $adapterScope); if ($specifiedTypes !== null) { return $specifiedTypes; } @@ -798,8 +854,7 @@ private function specifyTypesViaResults( $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - // TODO(new-world): port assert narrowing off TypeSpecifier - $specifiedTypes = $this->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $adapterScope); + $specifiedTypes = $this->specifyTypesFromAssertsViaResults($context, $expr, $asserts, $parametersAcceptor, $adapterScope); if ($specifiedTypes !== null) { return $specifiedTypes; } @@ -817,6 +872,228 @@ private function specifyTypesViaResults( return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * @param callable(): Type $returnTypeCallback + */ + /** + * New-world copy of TypeSpecifier::specifyTypesFromConditionalReturnType(). + * The passed scope is a ResultAwareScope, which keeps the @api + * TypeSpecifier::create()/specifyTypesInCondition() calls in the new world. + */ + private function specifyTypesFromConditionalReturnTypeViaResults( + TypeSpecifierContext $context, + Expr\CallLike $call, + ?ParametersAcceptor $parametersAcceptor, + MutatingScope $scope, + ): ?SpecifiedTypes + { + if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { + return null; + } + + $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); + if (!$returnType instanceof ConditionalTypeForParameter) { + return null; + } + + if ($context->true()) { + $leftType = new ConstantBooleanType(true); + $rightType = new ConstantBooleanType(false); + } elseif ($context->false()) { + $leftType = new ConstantBooleanType(false); + $rightType = new ConstantBooleanType(true); + } elseif ($context->null()) { + $leftType = new MixedType(); + $rightType = new NeverType(); + } else { + return null; + } + + $argumentExpr = null; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } else { + continue; + } + + if ($returnType->getParameterName() !== '$' . $paramName) { + continue; + } + + $argumentExpr = $arg->value; + } + + if ($argumentExpr === null) { + return null; + } + + $targetType = $returnType->getTarget(); + $ifType = $returnType->getIf(); + $elseType = $returnType->getElse(); + + if ( + ( + $argumentExpr instanceof Node\Scalar + || ($argumentExpr instanceof Expr\ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) + ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) + ) { + return null; + } + + if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { + $conditionContext = $returnType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); + } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { + $conditionContext = $returnType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + } else { + return null; + } + + $specifiedTypes = $this->typeSpecifier->create( + $argumentExpr, + $targetType, + $conditionContext, + $scope, + ); + + if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { + if ($targetType->isFalse()->yes()) { + $conditionContext = $conditionContext->negate(); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->specifyTypesInCondition($scope, $argumentExpr, $conditionContext)); + } + + return $specifiedTypes; + } + + /** + * New-world copy of TypeSpecifier::specifyTypesFromAsserts(). + */ + private function specifyTypesFromAssertsViaResults(TypeSpecifierContext $context, Expr\CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, MutatingScope $scope): ?SpecifiedTypes + { + if ($context->null()) { + $asserts = $assertions->getAsserts(); + } elseif ($context->true()) { + $asserts = $assertions->getAssertsIfTrue(); + } elseif ($context->false()) { + $asserts = $assertions->getAssertsIfFalse(); + } else { + throw new ShouldNotHappenException(); + } + + if (count($asserts) === 0) { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = array_last($parameters); + $paramName = $lastParameter->getName(); + } else { + continue; + } + + $argsMap[$paramName][] = $arg->value; + } + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $defaultValue = $parameter->getDefaultValue(); + if (isset($argsMap[$name]) || $defaultValue === null) { + continue; + } + $argsMap[$name][] = new TypeExpr($defaultValue); + } + + if ($call instanceof MethodCall) { + $argsMap['this'] = [$call->var]; + } + + /** @var SpecifiedTypes|null $types */ + $types = null; + + foreach ($asserts as $assert) { + foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { + $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $argsMap)) { + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); + return $type->toConditional($argType); + } + return $type; + } + } + + return $traverse($type); + }); + + $assertExpr = $assert->getParameter()->getExpr($parameterExpr); + + $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $containsUnresolvedTemplate = false; + TypeTraverser::map( + $assert->getOriginalType(), + static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { + if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { + $resolvedType = $templateTypeMap->getType($type->getName()); + if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + $containsUnresolvedTemplate = true; + return $type; + } + } + + return $traverse($type); + }, + ); + + $newTypes = $this->typeSpecifier->create( + $assertExpr, + $assertedType, + $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), + $scope, + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; + + if (!$context->null() || (!$assertedType->isTrue()->yes() && !$assertedType->isFalse()->yes())) { + continue; + } + + $subContext = $assertedType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + if ($assert->isNegated()) { + $subContext = $subContext->negate(); + } + + $types = $types->unionWith($this->typeSpecifier->specifyTypesInCondition( + $scope, + $assertExpr, + $subContext, + )); + } + } + + return $types; + } + /** * @param callable(): Type $returnTypeCallback */ diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 32bff596de..61ff8bc3f9 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -55,7 +55,7 @@ public function specifyDefaultTypes(Expr $expr, Type $exprType, TypeSpecifierCon $sureNotTypes[$this->exprPrinter->printExpr($originalExpr)] = [$originalExpr, $removedType]; } - return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($originalExpr); + return (new SpecifiedTypes(sureNotTypes: $sureNotTypes))->setRootExpr($originalExpr); } } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 4938dc24ba..9a656c4a1c 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -20,6 +21,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -37,6 +39,7 @@ final class VariableHandler implements ExprHandler public function __construct( private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -96,6 +99,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof Variable) { + throw new ShouldNotHappenException(); + } + + if (is_string($e->name)) { + if ($s->hasVariableType($e->name)->no()) { + return new ErrorType(); + } + + return $s->getVariableType($e->name); + } + + // dynamic variable names need per-constant-string equality narrowing, + // which requires the BinaryOp equality migration first — guarded + // legacy bridge until then (works under PHPSTAN_FNSR=0) + return $s->getType($e); + }; + return new ExpressionResult( $scope, $hasYield, @@ -105,7 +127,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex static fn (): MutatingScope => $scope->filterByTruthyValue($expr), static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, - typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->resolveType($s, $e), + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index c952fcc129..169ca2851c 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\NativeTypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +27,12 @@ final class NativeTypeExprHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof NativeTypeExpr; @@ -35,12 +43,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof NativeTypeExpr) { + throw new ShouldNotHappenException(); + } + + if ($s->nativeTypesPromoted) { + return $e->getNativeType(); + } + + return $e->getPhpDocType(); + }; + return new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 81c6c9f08f..07e98a5330 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +27,12 @@ final class TypeExprHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof TypeExpr; @@ -35,12 +43,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof TypeExpr) { + throw new ShouldNotHappenException(); + } + + return $e->getExprType(); + }; + return new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7e77ba505e..c064816a82 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -131,7 +131,12 @@ public function getNativeType(): Type return $this->cachedNativeType = $this->scope->getNativeType($this->expr); } - return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($this->scope->doNotTreatPhpDocTypesAsCertain())); + $promotedScope = $this->scope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($promotedScope)); } /** @@ -173,9 +178,14 @@ public function getSpecifiedTypes(MutatingScope $scope, TypeSpecifierContext $co public function getTruthyScope(): MutatingScope { + if ($this->truthyScope !== null) { + return $this->truthyScope; + } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { - return $this->scope->filterBySpecifiedTypes( + return $this->truthyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), + [$this->scope->getNodeKey($this->expr) => $this], ); } @@ -183,10 +193,6 @@ public function getTruthyScope(): MutatingScope return $this->scope; } - if ($this->truthyScope !== null) { - return $this->truthyScope; - } - $callback = $this->truthyScopeCallback; $this->truthyScope = $callback(); return $this->truthyScope; @@ -194,9 +200,14 @@ public function getTruthyScope(): MutatingScope public function getFalseyScope(): MutatingScope { + if ($this->falseyScope !== null) { + return $this->falseyScope; + } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { - return $this->scope->filterBySpecifiedTypes( + return $this->falseyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), + [$this->scope->getNodeKey($this->expr) => $this], ); } @@ -204,10 +215,6 @@ public function getFalseyScope(): MutatingScope return $this->scope; } - if ($this->falseyScope !== null) { - return $this->falseyScope; - } - $callback = $this->falseyScopeCallback; $this->falseyScope = $callback(); return $this->falseyScope; diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index f1e8ef6301..9050b875eb 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -12,42 +12,36 @@ final class ExpressionResultStorage { - /** @var SplObjectStorage */ - private SplObjectStorage $scopes; - /** @var SplObjectStorage */ private SplObjectStorage $results; - /** @var array, request: ExpressionResultForExprRequest}> */ + /** @var array, request: ExpressionResultForExprRequest}> */ public array $pendingFibers = []; - /** @var list> */ + /** @var list> */ public array $parkedFibers = []; + /** + * Expressions currently being processed on demand by ResultAwareScope — + * descendants (which work on duplicates) detect ancestor cycles through this. + * + * @var array + */ + public array $syntheticsInFlight = []; + public function __construct() { - $this->scopes = new SplObjectStorage(); $this->results = new SplObjectStorage(); } public function duplicate(): self { $new = new self(); - $new->scopes->addAll($this->scopes); $new->results->addAll($this->results); + $new->syntheticsInFlight = $this->syntheticsInFlight; return $new; } - public function storeBeforeScope(Expr $expr, Scope $scope): void - { - $this->scopes[$expr] = $scope; - } - - public function findBeforeScope(Expr $expr): ?Scope - { - return $this->scopes[$expr] ?? null; - } - public function storeResult(Expr $expr, ExpressionResult $result): void { $this->results[$expr] = $result; diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 9ffa1408fb..7a576e4fb8 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; @@ -31,14 +32,22 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + if ($nodeCallback instanceof NoopNodeCallback) { + return; + } + if (Fiber::getCurrent() !== null) { $nodeCallback($node, $scope->toFiberScope()); return; } if (count($storage->parkedFibers) > 0) { $fiber = array_pop($storage->parkedFibers); + if ($fiber === null) { + throw new ShouldNotHappenException(); + } $request = $fiber->resume([$nodeCallback, $node, $scope]); } else { + /** @var Fiber $fiber */ $fiber = new Fiber(static function () use ($node, $scope, $nodeCallback) { while (true) { // @phpstan-ignore while.alwaysTrue $nodeCallback($node, $scope->toFiberScope()); @@ -57,7 +66,7 @@ public function storeResult(ExpressionResultStorage $storage, Expr $expr, Expres } /** - * @param Fiber $fiber + * @param Fiber $fiber */ private function runFiberForNodeCallback( ExpressionResultStorage $storage, @@ -113,7 +122,8 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void $result = $this->processExprNode( new Node\Stmt\Expression($request->expr), $request->expr, - $request->scope, + // process on the plain scope — a FiberScope would suspend from within + $request->scope->toMutatingScope(), $storage, static function (): void { }, diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 87b21b24ff..fee3fd8af9 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -131,22 +131,6 @@ public function filterByFalseyValue(Expr $expr): self return $scope; } - private function preprocessScope(MutatingScope $scope): Scope - { - if ($this->nativeTypesPromoted) { - $scope = $scope->doNotTreatPhpDocTypesAsCertain(); - } - - foreach ($this->truthyValueExprs as $expr) { - $scope = $scope->filterByTruthyValue($expr); - } - foreach ($this->falseyValueExprs as $expr) { - $scope = $scope->filterByFalseyValue($expr); - } - - return $scope; - } - /** * @param MethodReflection|FunctionReflection|null $reflection */ diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b11ee53567..d2aa81df6e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3445,6 +3445,142 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } + return $this->applySpecifiedExpressionsToConditionals($scope, $specifiedTypes, $specifiedExpressions); + } + + /** + * New-world replacement for filterBySpecifiedTypes(): applies SpecifiedTypes + * without resolving expression types through the guarded Scope::getType(). + * Original (pre-narrowing) types are resolved in tiers: ExpressionTypeResolver + * extensions, scope-tracked holders, ExpressionResults supplied by the caller, + * guarded legacy bridge (PHPSTAN_FNSR=0). + * + * @param array $exprResults + */ + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes, array $exprResults = []): self + { + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + + $scope = $this; + $specifiedExpressions = []; + foreach ($typeSpecifications as $typeSpecification) { + $expr = $typeSpecification['expr']; + $type = $typeSpecification['type']; + + if ($expr instanceof IssetExpr) { + $issetExpr = $expr; + $expr = $issetExpr->getExpr(); + + if ($typeSpecification['sure']) { + $scope = $scope->setExpressionCertainty( + $expr, + TrinaryLogic::createMaybe(), + ); + } else { + $scope = $scope->unsetExpression($expr); + } + + continue; + } + + [$originalType, $originalNativeType] = $scope->resolveOriginalTypesForApply($expr, $exprResults); + + if ($typeSpecification['sure']) { + if ($specifiedTypes->shouldOverwrite()) { + $scope = $scope->assignExpression($expr, $type, $type); + $newType = $type; + } elseif ($scope->isComplexUnionType($originalType)) { + // mirrors addTypeToExpression() + $newType = $originalType; + } else { + $newType = TypeCombinator::intersect($type, $originalType); + $newNativeType = $originalType->equals($originalNativeType) ? $newType : TypeCombinator::intersect($type, $originalNativeType); + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + } elseif ($type instanceof NeverType || $originalType instanceof NeverType || $scope->isComplexUnionType($originalType)) { + // mirrors removeTypeFromExpression() + $newType = $originalType; + } else { + $newType = TypeCombinator::remove($originalType, $type); + $scope = $scope->specifyExpressionType($expr, $newType, TypeCombinator::remove($originalNativeType, $type), TrinaryLogic::createYes()); + } + + $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, TypeUtils::resolveLateResolvableTypes($newType)); + } + + return $this->applySpecifiedExpressionsToConditionals($scope, $specifiedTypes, $specifiedExpressions); + } + + /** + * @param array $exprResults + * @return array{Type, Type} + */ + private function resolveOriginalTypesForApply(Expr $expr, array $exprResults): array + { + foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { + $extensionType = $extension->getType($expr, $this); + if ($extensionType !== null) { + return [$extensionType, $extensionType]; + } + } + + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if (array_key_exists($exprString, $this->expressionTypes)) { + $nativeHolder = $this->nativeExpressionTypes[$exprString] ?? $this->expressionTypes[$exprString]; + + return [ + TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()), + TypeUtils::resolveLateResolvableTypes($nativeHolder->getType()), + ]; + } + + if (array_key_exists($exprString, $exprResults)) { + return [$exprResults[$exprString]->getType(), $exprResults[$exprString]->getNativeType()]; + } + } + + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return [$this->getType($expr), $this->getNativeType($expr)]; + } + + /** + * @param array $specifiedExpressions + * @return static + */ + private function applySpecifiedExpressionsToConditionals(self $scope, SpecifiedTypes $specifiedTypes, array $specifiedExpressions): self + { $conditions = []; $originallySpecifiedExprStrings = $specifiedExpressions; $prevSpecifiedCount = -1; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0d14269619..374316dfab 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -180,6 +180,7 @@ use function is_array; use function is_int; use function is_string; +use function spl_object_id; use function sprintf; use function strtolower; use function trim; @@ -1149,7 +1150,10 @@ public function processStmtNode( } $scope = $result->getScope(); if ($result->hasSpecifiedTypesCallback()) { - $scope = $scope->filterBySpecifiedTypes($result->getSpecifiedTypes($scope, TypeSpecifierContext::createNull())); + $scope = $scope->applySpecifiedTypes( + $result->getSpecifiedTypes($scope, TypeSpecifierContext::createNull()), + [$scope->getNodeKey($stmt->expr) => $result], + ); } else { $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( $scope, @@ -1310,9 +1314,11 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $stmt->type, $scope, $storage); } } elseif ($stmt instanceof If_) { - $conditionType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); - $ifAlwaysTrue = $conditionType->isTrue()->yes(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $conditionType = (NewWorld::isEnabled() + ? ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType()) + : ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond)))->toBoolean(); + $ifAlwaysTrue = $conditionType->isTrue()->yes(); $exitPoints = []; $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -1346,8 +1352,11 @@ public function processStmtNode( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); - $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condScope->getType($elseif->cond) : $scope->getNativeType($elseif->cond))->toBoolean(); + $elseIfCondScopeBefore = $condScope; $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $elseIfConditionType = (NewWorld::isEnabled() + ? ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType()) + : ($this->treatPhpDocTypesAsCertain ? $elseIfCondScopeBefore->getType($elseif->cond) : $scope->getNativeType($elseif->cond)))->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); $condScope = $condResult->getScope(); @@ -3537,6 +3546,7 @@ public function processArgs( $processingOrder = array_keys($args); $hasReorderedArgs = false; + $argExprTypes = []; foreach ($args as $arg) { if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { $hasReorderedArgs = true; @@ -3726,6 +3736,7 @@ public function processArgs( } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); $exprType = $exprResult->getType(); + $argExprTypes[spl_object_id($arg->value)] = $exprType; $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -3828,7 +3839,9 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + // by-value args were just processed — reuse the result type; + // by-ref args keep the guarded legacy bridge (PHPSTAN_FNSR=0) + $argType = $argExprTypes[spl_object_id($arg->value)] ?? $scope->getType($arg->value); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { diff --git a/src/Analyser/ResultAwareScope.php b/src/Analyser/ResultAwareScope.php index 7e7f62439b..7021ea6ff6 100644 --- a/src/Analyser/ResultAwareScope.php +++ b/src/Analyser/ResultAwareScope.php @@ -69,7 +69,8 @@ public function initializeResultAware( public function toResultAwareScope(array $exprResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage): self { if ($this->plainScope === null) { - throw new ShouldNotHappenException(); + // derived through an uncovered scope-mutation path — start fresh from this state + return parent::toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, $storage); } // don't wrap an adapter in an adapter — merge the known results instead @@ -110,7 +111,9 @@ public function doNotTreatPhpDocTypesAsCertain(): Scope } if ($this->plainScope === null || $this->nodeScopeResolver === null || $this->stmt === null || $this->resultStorage === null) { - throw new ShouldNotHappenException(); + // derived through an uncovered scope-mutation path — degrade to the + // plain promoted scope (guarded legacy bridge, PHPSTAN_FNSR=0) + return parent::doNotTreatPhpDocTypesAsCertain(); } $promotedPlainScope = $this->plainScope->doNotTreatPhpDocTypesAsCertain(); @@ -127,20 +130,35 @@ public function doNotTreatPhpDocTypesAsCertain(): Scope } /** - * Resolves SpecifiedTypes for the given expr through ExpressionResults — - * called from the head of TypeSpecifier::specifyTypesInCondition() so that - * old-world narrowing code recursing with this scope stays in the new world. + * The ExpressionResult for the given expr — a known child result, or the + * expression processed on demand. Used by the head of + * TypeSpecifier::specifyTypesInCondition() so that old-world narrowing code + * recursing with this scope stays in the new world where possible. * * @internal */ - public function specifyTypesViaResults(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + public function getExpressionResultForExpr(Expr $expr): ExpressionResult { $key = $this->getNodeKey($expr); if (array_key_exists($key, $this->exprResults)) { - return $this->exprResults[$key]->getSpecifiedTypes($this, $context); + return $this->exprResults[$key]; + } + + if ($this->plainScope === null || ($this->resultStorage !== null && array_key_exists($key, $this->resultStorage->syntheticsInFlight))) { + // no adapter context (derived through an uncovered scope-mutation path), + // or this expression is already being processed up the stack — return a + // callback-less result so the caller takes its guarded legacy bridge + return new ExpressionResult( + $this, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr, + ); } - return $this->processSynthetic($expr)->getSpecifiedTypes($this, $context); + return $this->processSynthetic($expr); } /** @@ -205,9 +223,13 @@ private function resolveTypeViaResults(Expr $node): Type return $this->exprResults[$key]->getTypeForScope($this); } - if ($this->plainScope === null) { - // derived through a scope-mutation path that did not carry the adapter - // context — degrade to the guarded legacy bridge (PHPSTAN_FNSR=0) + if ( + $this->plainScope === null + || ($this->resultStorage !== null && array_key_exists($key, $this->resultStorage->syntheticsInFlight)) + ) { + // no adapter context, or this very expression is already being processed + // somewhere up the stack — degrade to the guarded legacy bridge + // (PHPSTAN_FNSR=0) instead of recursing return parent::getType($node); } @@ -220,11 +242,14 @@ private function processSynthetic(Expr $expr): ExpressionResult throw new ShouldNotHappenException('ResultAwareScope is missing its adapter context.'); } + $storage = $this->resultStorage->duplicate(); + $storage->syntheticsInFlight[$this->getNodeKey($expr)] = true; + return $this->nodeScopeResolver->processExprNode( $this->stmt, $expr, $this->plainScope, - $this->resultStorage->duplicate(), + $storage, new NoopNodeCallback(), ExpressionContext::createDeep(), ); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 2e6e3a6699..37f21797bd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -95,12 +95,22 @@ public function specifyTypesInCondition( if ($scope instanceof ResultAwareScope) { // new world: old-world narrowing code recursing with the adapter // stays in the new world — resolved through ExpressionResults - return $scope->specifyTypesViaResults($expr, $context); - } + $result = $scope->getExpressionResultForExpr($expr); + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } - if ($scope instanceof FiberScope) { + // not-yet-migrated handler — fall through to the guarded old-world + // dispatcher, keeping the adapter so inner lookups stay unguarded + } elseif ($scope instanceof FiberScope) { // new world: rules asking for narrowing suspend for the ExpressionResult - return $scope->getExpressionResult($expr)->getSpecifiedTypes($scope->toMutatingScope(), $context); + $result = $scope->getExpressionResult($expr); + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope->toMutatingScope(), $context); + } + + // not-yet-migrated handler — guarded old-world bridge (PHPSTAN_FNSR=0) + $scope = $scope->toMutatingScope(); } $enableFnsr = getenv('PHPSTAN_FNSR'); diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 4b3872b40c..dd019839f5 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -47,4 +47,96 @@ public function functionCalls(int $i, string $s): void assertType('float', $pi); } + public function narrowingInIf(string $s): void + { + $v = 1; + if ($v) { + assertType('1', $v); + } else { + assertType('*NEVER*', $v); + } + + $w = rand(0, 1); + assertType('int<0, 1>', $w); + if ($w) { + assertType('1', $w); + } else { + assertType('0', $w); + } + + $len = strlen($s); + assertType('int<0, max>', $len); + if ($len) { + assertType('int<1, max>', $len); + } else { + assertType('0', $len); + } + } + + public function assignInCondition(string $s): void + { + if ($len = strlen($s)) { + assertType('int<1, max>', $len); + } else { + assertType('0', $len); + } + } + + public function functionAsserts(): void + { + $m = mixedValue(); + assertType('mixed', $m); + assertInt($m); + assertType('int', $m); + } + + public function conditionalReturnType(int $i): void + { + assertType('bool', isPositive($i)); + if (isPositive($i)) { + assertType('int<1, max>', $i); + } else { + assertType('int', $i); + } + } + + public function conditionalExpressionHolders(string $s): void + { + $len = strlen($s); + if ($len) { + assertType('non-empty-string', $s); + assertType('int<1, max>', $len); + } else { + assertType('\'\'', $s); + assertType('0', $len); + } + } + + public function assignByReference(): void + { + $q = 1; + $r = &$q; + assertType('1', $r); + } + +} + +function mixedValue(): mixed +{ + return 1; +} + +/** + * @phpstan-assert int $value + */ +function assertInt(mixed $value): void +{ +} + +/** + * @return ($i is int<1, max> ? true : false) + */ +function isPositive(int $i): bool +{ + return $i >= 1; } From 0f3c002167e7741f2ca4c0509ba69102e0cf4b85 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 13:07:17 +0200 Subject: [PATCH 06/50] Migrate Array, BinaryOp and inc/dec handlers; fix premature pending-fiber flush and first-class-callable result expr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array leg: per-item types are captured at their own evaluation points, so `[$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c]` infers `array{1, 2, 1, 3, 1, 2}` — the old world resolves all items on a single scope and cannot do this. processVirtualAssign takes an optional assigned-type callback (auto-priced for TypeExpr/NativeTypeExpr); PreInc/PreDec extract a pure resolveTypeFromVarType() shared by both worlds; PostInc/PostDec type as the pre-step value. Engine fixes found by make-phpstan divergence triage: 1. Statement lists no longer flush pending fibers. The flush ran at the end of every nested list, so a fiber asking about an expression the enclosing statement still had to process (a loop condition after its body) was synthetically answered on the stale suspension scope and stored under the real AST node's key, early-resuming later legitimate askers (`do { $count++ } while ($count < 3)` reported "0 < 3 always true"). Pending requests now wait for the real storeResult resume; only analysis-unit boundaries (file, function, method, trait) flush genuine synthetics. 2. The first-class-callable early path now rewraps its result with the original expr. It used to carry the virtual *CallableNode, whose resolveType is intentionally mixed, so `$f = strlen(...)` typed $f as mixed in both worlds. NewWorldTypeInferenceTest corpus grown 34 -> 132 assertions, driven by a raw xdebug coverage audit of all branch changes (PHPUnit per-test coverage misses data providers): all BinaryOp operators, inc/dec variants, extension probes (is_int, assert, intdiv throw certainty), assertNativeType, bridge probes for unmigrated constructs. Coverage of executable changed lines: 47.5%; the rest classified in NEW_WORLD.md (old-world bodies, defensive throws, rule-driven paths, future-leg provisions). nsrt mixed-mode failures 45 -> 31 (0 errors); make phpstan divergences 30 -> 13. --- NEW_WORLD.md | 140 ++++++++ src/Analyser/ExprHandler/ArrayHandler.php | 51 +++ src/Analyser/ExprHandler/BinaryOpHandler.php | 149 +++++++- src/Analyser/ExprHandler/PostDecHandler.php | 22 ++ src/Analyser/ExprHandler/PostIncHandler.php | 22 ++ src/Analyser/ExprHandler/PreDecHandler.php | 42 ++- src/Analyser/ExprHandler/PreIncHandler.php | 42 ++- src/Analyser/NodeScopeResolver.php | 78 ++-- tests/PHPStan/Analyser/data/new-world.php | 352 +++++++++++++++++++ 9 files changed, 860 insertions(+), 38 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 48f93e21b4..a460f3bdb4 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -231,6 +231,85 @@ Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive the code states that dependency as a fact (and bridges or skips), it doesn't promise future work. +## 5b. Handler migration checklist + +`[x]` = `processExpr` wires both `typeCallback` and `specifyTypesCallback` into its +`ExpressionResult`. Residual guarded bridges inside migrated handlers are documented +as factual comments at their call sites, not here. + +### Expression handlers + +- [ ] ArrayDimFetchHandler +- [x] ArrayHandler +- [ ] ArrowFunctionHandler +- [x] AssignHandler — Ternary/Match conditional-expression holders stay old-world until those handlers migrate +- [ ] AssignOpHandler +- [ ] BinaryOpHandler — `typeCallback` done (Identical/NotIdentical bridge until the equality migration); `specifyTypesCallback` missing +- [ ] BitwiseNotHandler +- [ ] BooleanAndHandler +- [ ] BooleanNotHandler +- [ ] BooleanOrHandler +- [ ] CastHandler +- [ ] CastStringHandler +- [ ] ClassConstFetchHandler +- [ ] CloneHandler +- [ ] ClosureHandler +- [ ] CoalesceHandler +- [ ] ConstFetchHandler +- [ ] EmptyHandler +- [ ] ErrorSuppressHandler +- [ ] EvalHandler +- [ ] ExitHandler +- [ ] FirstClassCallableFuncCallHandler +- [ ] FirstClassCallableMethodCallHandler +- [ ] FirstClassCallableNewHandler +- [ ] FirstClassCallableStaticCallHandler +- [x] FuncCallHandler — dynamic-name calls bridge +- [ ] IncludeHandler +- [ ] InstanceofHandler +- [ ] InterpolatedStringHandler +- [ ] IssetHandler +- [ ] MatchHandler +- [ ] MethodCallHandler +- [ ] NewHandler +- [ ] NullsafeMethodCallHandler +- [ ] NullsafePropertyFetchHandler +- [ ] PipeHandler +- [x] PostDecHandler +- [x] PostIncHandler +- [x] PreDecHandler +- [x] PreIncHandler +- [ ] PrintHandler +- [ ] PropertyFetchHandler +- [x] ScalarHandler +- [ ] StaticCallHandler +- [ ] StaticPropertyFetchHandler +- [ ] TernaryHandler +- [ ] ThrowHandler +- [ ] UnaryMinusHandler +- [ ] UnaryPlusHandler +- [x] VariableHandler — dynamic variable names bridge +- [ ] YieldFromHandler +- [ ] YieldHandler + +### Virtual node handlers + +- [ ] AlwaysRememberedExprHandler +- [ ] ExistingArrayDimFetchHandler +- [ ] FunctionCallableNodeHandler +- [ ] GetIterableKeyTypeExprHandler +- [ ] GetIterableValueTypeExprHandler +- [ ] GetOffsetValueTypeExprHandler +- [ ] InstantiationCallableNodeHandler +- [ ] MethodCallableNodeHandler +- [x] NativeTypeExprHandler +- [ ] OriginalPropertyTypeExprHandler +- [ ] SetExistingOffsetValueTypeExprHandler +- [ ] SetOffsetValueTypeExprHandler +- [ ] StaticMethodCallableNodeHandler +- [x] TypeExprHandler +- [ ] UnsetOffsetExprHandler + ## 6. Migration mechanics - **Exercisers**: tiny files analysed with `bin/phpstan analyse -l 8 test.php --debug` under @@ -295,3 +374,64 @@ Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive function asserts (`@phpstan-assert`), conditional return types, holder-driven narrowing (`$len = strlen($s); if ($len)` → `$s` is `non-empty-string`), and by-reference assignment. +- 2026-06-10 (array leg): **`ArrayHandler`, `BinaryOpHandler`, `Pre/PostInc`, + `Pre/PostDec` migrate.** The headline test: `$a = [$b = 1, $b + 1, $c = $b, + $c + 2, $c++, $c]` infers `array{1, 2, 1, 3, 1, 2}` — each item's type is + captured at its own evaluation point (the old world resolves all items on one + scope and cannot do this). `processVirtualAssign` takes an optional + `$assignedTypeCallback` (auto-priced for `TypeExpr`/`NativeTypeExpr`); + `PreInc/PreDec` extract a pure `resolveTypeFromVarType(Expr, Type)` shared by + both worlds; `PostInc/PostDec` type as the variable's pre-step value and price + the virtual `PreInc/PreDec` assign via the injected pre-handler. BinaryOp's + `resolveTypeFromResults` is a full copy of `resolveType` with identity-matched + child results (Identical/NotIdentical bridge to `RicherScopeGetTypeHelper` + until the equality migration). +- 2026-06-10 (engine fixes found by the leg, via `make phpstan` divergence triage): + 1. **Pending fibers parked too eagerly flushed**: the flush ran at the end of + *every* statement list, so a fiber asking about an expr that the enclosing + statement still had to process (a do-while/while/for condition after its body + list) was answered by synthetic re-processing on the scope captured at + suspension — and the poisoned result was stored under the *real* AST node's + key, early-resuming later legitimate askers (`do { $count++ } while ($count + < 3)` reported `0 < 3 always true`). Fix: statement lists never flush; + parked requests wait for the real `storeResult` resume, and only + analysis-unit boundaries (file statements, function, method, trait) flush + genuine synthetics. Resolved 7 `smaller.alwaysTrue` + ~10 loop-flavored + src divergences. + 2. **First-class callables typed `mixed` when assigned** (both worlds — + a consolidation regression): the FCC early path stored the virtual + `*CallableNode`'s result, whose `expr` was the virtual node; the virtual + handler's `resolveType` is intentionally `mixed`, so the result bridge + asked the wrong node. Fix: rewrap the result with the original expr so the + bridge dispatches to the `FirstClassCallable*Handler`s. + `make phpstan` (4G) divergences: 30 → 13; nsrt mixed failures: 45 → 31 (0 errors). +- 2026-06-10 (corpus + coverage): user-driven xdebug coverage audit of all branch + changes vs 2.2.x under `NewWorldTypeInferenceTest` (raw whole-process coverage — + PHPUnit per-test coverage misses data providers where the analysis runs). + Corpus grown 34 → 132 assertions: all BinaryOp operators, pre/post inc/dec + variants, keyed arrays, `is_callable` pair check, nullable truthy narrowing, + post-inc-in-condition (exprResults tier of `applySpecifiedTypes`), + `assertNativeType` probes, method-call result bridge, tracked-property and + is_* narrowing through `ResultAwareScope` + `TypeSpecifier` head-checks, + dynamic/undefined variables, unmigrated-condition bridges (BooleanNot/And/Or, + instanceof, empty, isset, count), bare-call statements, negated/equality + asserts, first-class callables, list assignment, closures/arrow fns, foreach + virtual assigns, elseif, echo, min/max signature selection, dynamic + return/type-specifying/throw extension probes (`is_int`, `assert`, `intdiv` + try/finally certainty). **Coverage of executable changed lines: 47.5%.** + Remaining uncovered, classified: old-world bodies moved by consolidation + (covered by the pre-existing suite; deleted in 3.0), defensive throws, + rule-driven paths (fiber early-resume/synthetic flush, `FiberScope` + doNotTreat.../keepVoid — TypeInferenceTestCase runs no rules), + `ExpressionTypeResolverExtension` tiers (no such extension in test config), + and future-leg provisions (isset-certainty apply branch, TruthyFalsey + context, nullsafe roots in migrated specify callbacks). +- **Known engine debt — `ExpressionResultStorage` memory retention**: every + `ExpressionResult` (holding its after-scope, callbacks, memoized types) is + retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker + limits and OOMs at the default 599M in nested-foreach files + (`SplObjectStorage::addAll` in `duplicate()`). Pre-existing at HEAD before the + array leg (5 OOM errors baseline vs 7 with it). Needs an eviction strategy + (results evictable once no fiber/conditional holder can still ask — e.g. + per-statement or per-function clearing, or weak references); per project + discipline the fix is algorithmic, not a memory-limit bump. diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index f64a168e84..620ce76c00 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -12,6 +12,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -22,11 +23,14 @@ use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_key_exists; use function array_merge; use function count; +use function spl_object_id; /** * @implements ExprHandler @@ -37,6 +41,7 @@ final class ArrayHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -73,6 +78,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $itemNodes = []; + $itemResults = []; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -82,6 +88,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage); if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->key)] = $keyResult; $hasYield = $hasYield || $keyResult->hasYield(); $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); @@ -90,6 +97,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->value)] = $valueResult; $hasYield = $hasYield || $valueResult->hasYield(); $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); @@ -98,12 +106,55 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); + // each item type was captured at its own evaluation point in the sequence — + // resolving them on any single scope (the old world) cannot handle items + // with side effects like `[$b = 1, $b + 1, $b++]` + $typeCallback = function (Expr $e, MutatingScope $s) use ($itemResults): Type { + if (!$e instanceof Array_) { + throw new ShouldNotHappenException(); + } + + $type = $this->initializerExprTypeResolver->getArrayType($e, static function (Expr $inner) use ($itemResults, $s): Type { + $id = spl_object_id($inner); + if (array_key_exists($id, $itemResults)) { + return $itemResults[$id]->getTypeForScope($s); + } + + // getArrayType only asks about item keys and values — guarded + // legacy bridge just in case (PHPSTAN_FNSR=0) + return $s->getType($inner); + }); + + if ( + count($e->items) === 2 + && isset($e->items[0], $e->items[1]) + && $type->isCallable()->maybe() + ) { + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), + [new Arg($e)], + ); + $isCallableCallString = $s->getNodeKey($isCallableCall); + if ( + array_key_exists($isCallableCallString, $s->expressionTypes) + && $s->expressionTypes[$isCallableCallString]->getType()->isTrue()->yes() + ) { + $type = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 658482be9e..07ea392134 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -89,7 +89,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && - !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$rightResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } @@ -101,6 +101,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $scope = $rightResult->getScope(); + $typeCallback = function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BinaryOp) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromResults($e, $s, $leftResult, $rightResult); + }; + return new ExpressionResult( $scope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), @@ -109,9 +117,148 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, ); } + /** + * New-world copy of resolveType(): operand types come from the child + * ExpressionResults (captured at their own evaluation points). Synthetic + * sub-expressions and the identical/not-identical helper still take the + * guarded legacy bridge (PHPSTAN_FNSR=0) until the equality migration. + */ + private function resolveTypeFromResults(BinaryOp $expr, MutatingScope $scope, ExpressionResult $leftResult, ExpressionResult $rightResult): Type + { + $getType = static function (Expr $inner) use ($expr, $scope, $leftResult, $rightResult): Type { + if ($inner === $expr->left) { + return $leftResult->getTypeForScope($scope); + } + if ($inner === $expr->right) { + return $rightResult->getTypeForScope($scope); + } + + return $scope->getType($inner); + }; + + if ($expr instanceof BinaryOp\Smaller) { + return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\Greater) { + return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { + if ( + $expr->left instanceof Variable + && is_string($expr->left->name) + && $expr->right instanceof Variable + && is_string($expr->right->name) + && $expr->left->name === $expr->right->name + ) { + return new ConstantBooleanType($expr instanceof BinaryOp\Equal); + } + + $equalType = $this->initializerExprTypeResolver->resolveEqualType($getType($expr->left), $getType($expr->right))->type; + if ($expr instanceof BinaryOp\Equal) { + return $equalType; + } + + if ($equalType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($equalType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + + if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { + // RicherScopeGetTypeHelper resolves operands through the scope — + // guarded legacy bridge until the equality migration (PHPSTAN_FNSR=0) + return $scope->getType($expr); + } + + if ($expr instanceof BinaryOp\LogicalXor) { + $leftBooleanType = $getType($expr->left)->toBoolean(); + $rightBooleanType = $getType($expr->right)->toBoolean(); + + $leftBooleanValue = $leftBooleanType->isTrue()->yes() ? true : ($leftBooleanType->isFalse()->yes() ? false : null); + $rightBooleanValue = $rightBooleanType->isTrue()->yes() ? true : ($rightBooleanType->isFalse()->yes() ? false : null); + if ($leftBooleanValue !== null && $rightBooleanValue !== null) { + return new ConstantBooleanType( + $leftBooleanValue xor $rightBooleanValue, + ); + } + + return new BooleanType(); + } + + if ($expr instanceof BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); + } + + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $getType = static fn (Expr $expr): Type => $scope->getType($expr); diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 6c38de4a62..d6891a56f7 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -26,6 +28,13 @@ final class PostDecHandler implements ExprHandler { + public function __construct( + private PreDecHandler $preDecHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostDec; @@ -35,6 +44,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the expression's value is the variable's type before the step + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PostDec) { + throw new ShouldNotHappenException(); + } + + return $varResult->getTypeForScope($s); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -42,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, new PreDec($expr->var), $nodeCallback, + fn (Expr $e, MutatingScope $s): Type => $this->preDecHandler->resolveTypeFromVarType($e instanceof PreDec ? $e->var : $e, $varResult->getTypeForScope($s)), )->getScope(); return new ExpressionResult( @@ -50,6 +69,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index f26c45c50f..7c9cc24404 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -26,6 +28,13 @@ final class PostIncHandler implements ExprHandler { + public function __construct( + private PreIncHandler $preIncHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostInc; @@ -35,6 +44,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the expression's value is the variable's type before the step + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PostInc) { + throw new ShouldNotHappenException(); + } + + return $varResult->getTypeForScope($s); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -42,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, new PreInc($expr->var), $nodeCallback, + fn (Expr $e, MutatingScope $s): Type => $this->preIncHandler->resolveTypeFromVarType($e instanceof PreInc ? $e->var : $e, $varResult->getTypeForScope($s)), )->getScope(); return new ExpressionResult( @@ -50,6 +69,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 4abde925a8..a9dcec9431 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Minus; use PhpParser\Node\Expr\PreDec; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,8 +18,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -40,6 +43,13 @@ final class PreDecHandler implements ExprHandler { + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreDec; @@ -47,7 +57,15 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varType = $scope->getType($expr->var); + return $this->resolveTypeFromVarType($expr->var, $scope->getType($expr->var)); + } + + /** + * The type of the decremented value, from the variable's own type — + * new-world copy of resolveType() usable by both worlds. + */ + public function resolveTypeFromVarType(Expr $varExpr, Type $varType): Type + { $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -66,7 +84,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type --$varValue; } - $newTypes[] = $scope->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { @@ -91,13 +109,25 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ]); } - return $scope->getType(new Minus($expr->var, new Int_(1))); + return $this->initializerExprTypeResolver->getMinusType( + $varExpr, + new Int_(1), + static fn (Expr $e): Type => $e === $varExpr ? $varType : ConstantTypeHelper::getTypeFromValue(1), + ); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PreDec) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromVarType($e->var, $varResult->getTypeForScope($s)); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -105,6 +135,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $typeCallback, )->getScope(); return new ExpressionResult( @@ -113,6 +144,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 1750b87654..45916b3b2d 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,8 +18,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -41,6 +44,13 @@ final class PreIncHandler implements ExprHandler { + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreInc; @@ -48,7 +58,15 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varType = $scope->getType($expr->var); + return $this->resolveTypeFromVarType($expr->var, $scope->getType($expr->var)); + } + + /** + * The type of the incremented value, from the variable's own type — + * new-world copy of resolveType() usable by both worlds. + */ + public function resolveTypeFromVarType(Expr $varExpr, Type $varType): Type + { $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -67,7 +85,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ++$varValue; } - $newTypes[] = $scope->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { @@ -92,13 +110,25 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ]); } - return $scope->getType(new Plus($expr->var, new Int_(1))); + return $this->initializerExprTypeResolver->getPlusType( + $varExpr, + new Int_(1), + static fn (Expr $e): Type => $e === $varExpr ? $varType : ConstantTypeHelper::getTypeFromValue(1), + ); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PreInc) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromVarType($e->var, $varResult->getTypeForScope($s)); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -106,6 +136,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $typeCallback, )->getScope(); return new ExpressionResult( @@ -114,6 +145,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 374316dfab..3fd6ae7b4f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -80,6 +80,7 @@ use PHPStan\Node\Expr\ForeachValueByRefExpr; use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; @@ -512,33 +513,17 @@ public function processStmtNodes( * @param Node\Stmt[] $stmts * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function processStmtNodesInternal( - Node $parentNode, - array $stmts, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - StatementContext $context, - ): InternalStatementResult - { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - ); - $this->processPendingFibers($storage); - - return $statementResult; - } - /** + * Does not flush pending fibers: a fiber parked on a not-yet-stored expression must + * keep waiting for the expression's real processing (e.g. a do-while condition is + * processed after its body's statement list ends). Pending fibers whose expressions + * never get traversed (synthetic nodes built by rules) are flushed at analysis-unit + * boundaries: end of file statements, function, method, and trait processing. + * * @param Node\Stmt[] $stmts * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function processStmtNodesInternalWithoutFlushingPendingFibers( + private function processStmtNodesInternal( Node $parentNode, array $stmts, MutatingScope $scope, @@ -2766,7 +2751,19 @@ public function processExprNode( throw new ShouldNotHappenException(); } - $result = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $innerResult = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + // carry the original expr, not the virtual callable node — the virtual + // node's resolveType is intentionally mixed, the first-class-callable + // handlers resolve types for the original expr (guarded legacy bridge, + // PHPSTAN_FNSR=0, until those handlers migrate) + $result = new ExpressionResult( + $innerResult->getScope(), + hasYield: $innerResult->hasYield(), + isAlwaysTerminating: $innerResult->isAlwaysTerminating(), + throwPoints: $innerResult->getThrowPoints(), + impurePoints: $innerResult->getImpurePoints(), + expr: $expr, + ); $this->storeResult($storage, $expr, $result); return $result; } @@ -3044,7 +3041,7 @@ public function processClosureNode( }; if (count($byRefUses) === 0) { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, @@ -3066,7 +3063,7 @@ public function processClosureNode( $prevScope = $closureScope; $storage = $originalStorage->duplicate(); - $intermediaryClosureScopeResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createTopLevel()); + $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createTopLevel()); $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); @@ -3094,7 +3091,7 @@ public function processClosureNode( } $storage = $originalStorage; - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, @@ -3996,8 +3993,23 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback): ExpressionResult + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param (callable(Expr, MutatingScope): Type)|null $assignedTypeCallback resolves + * the assigned value's type in the new world; when null, the virtual + * type wrappers answer directly and anything else takes the guarded + * legacy bridge (PHPSTAN_FNSR=0) + */ + public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?callable $assignedTypeCallback = null): ExpressionResult { + if ($assignedTypeCallback === null) { + if ($assignedExpr instanceof TypeExpr) { + $assignedTypeCallback = static fn (Expr $e, MutatingScope $s): Type => $assignedExpr->getExprType(); + } elseif ($assignedExpr instanceof NativeTypeExpr) { + $assignedTypeCallback = static fn (Expr $e, MutatingScope $s): Type => $s->nativeTypesPromoted ? $assignedExpr->getNativeType() : $assignedExpr->getPhpDocType(); + } + } + return $this->container->getByType(AssignHandler::class)->processAssignVar( $this, $scope, @@ -4007,7 +4019,15 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $assignedExpr, + typeCallback: $assignedTypeCallback, + ), false, ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index dd019839f5..d97e9bfd3e 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -2,7 +2,10 @@ namespace NewWorldTypeInference; +use PHPStan\TrinaryLogic; +use function PHPStan\Testing\assertNativeType; use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertVariableCertainty; class Foo { @@ -106,6 +109,7 @@ public function conditionalExpressionHolders(string $s): void if ($len) { assertType('non-empty-string', $s); assertType('int<1, max>', $len); + assertType('int<1, max>', strlen($s)); } else { assertType('\'\'', $s); assertType('0', $len); @@ -119,6 +123,340 @@ public function assignByReference(): void assertType('1', $r); } + /** + * Each item type is captured at its own evaluation point in the sequence — + * the old world resolves all items on a single scope and cannot get this right. + */ + public function arrayLiteralWithSequentialSideEffects(): void + { + $a = [ + $b = 1, + $b + 1, + $c = $b, + $c + 2, + $c++, + $c, + ]; + assertType('array{1, 2, 1, 3, 1, 2}', $a); + } + + public function comparisonOperators(int $i, int $j): void + { + assertType('bool', $i < $j); + assertType('bool', $i <= $j); + assertType('bool', $i > $j); + assertType('bool', $i >= $j); + assertType('true', 1 < 2); + assertType('true', 2 > 1); + assertType('false', 1 >= 2); + assertType('true', 1 <= 1); + } + + public function equalityOperators(int $i, int $j): void + { + assertType('true', $i == $i); + assertType('false', $i != $i); + assertType('bool', $i == $j); + assertType('bool', $i != $j); + assertType('true', 1 == 1); + assertType('false', 1 != 1); + assertType('bool', $i === $j); + assertType('bool', $i !== $j); + assertType('true', 1 === 1); + assertType('false', 1 !== 1); + } + + public function logicalAndArithmeticOperators(int $i, int $j, bool $b1, bool $b2): void + { + assertType('bool', $b1 xor $b2); + assertType('true', true xor false); + assertType('false', true xor true); + assertType('int<-1, 1>', $i <=> $j); + assertType('1', 2 <=> 1); + assertType("'ab'", 'a' . 'b'); + assertType('int', $i & $j); + assertType('4', 6 & 5); + assertType('int', $i | $j); + assertType('7', 6 | 5); + assertType('int', $i ^ $j); + assertType('3', 6 ^ 5); + assertType('5', 10 / 2); + assertType('(float|int)', $i / $j); + assertType('1', 10 % 3); + assertType('3', 5 - 2); + assertType('int', $i - $j); + assertType('10', 5 * 2); + assertType('25', 5 ** 2); + assertType('20', 5 << 2); + assertType('1', 5 >> 2); + } + + public function incrementDecrement(int $i): void + { + $a = 1; + $preInc = ++$a; + assertType('2', $preInc); + assertType('2', $a); + + $b = 5; + $preDec = --$b; + assertType('4', $preDec); + assertType('4', $b); + + $c = 7; + $postDec = $c--; + assertType('7', $postDec); + assertType('6', $c); + + $u = rand(0, 1) ? 1 : 5; + $u++; + assertType('2|6', $u); + + $d = rand(0, 1) ? 9 : 3; + $d--; + assertType('2|8', $d); + + $i++; + assertType('int', $i); + --$i; + assertType('int', $i); + } + + public function keyedArrayLiteral(int $i): void + { + $a = ['a' => 1, 'b' => $i]; + assertType('array{a: 1, b: int}', $a); + } + + public function callablePairArray(string $method): void + { + if (is_callable([$this, $method])) { + assertType('list{$this(NewWorldTypeInference\Foo), string}&callable(): mixed', [$this, $method]); + } + } + + public function nullableTruthyNarrowing(): void + { + $n = rand(0, 1) ? 'x' : null; + if ($n) { + assertType('\'x\'', $n); + } else { + assertType('null', $n); + } + } + + public function postIncInCondition(int $i): void + { + if ($i++) { + assertType('int', $i); + } + } + + /** + * @param positive-int $p + */ + public function nativeTypes(int $i, string $s, $p): void + { + assertNativeType('int', $i); + assertNativeType('int<0, max>', strlen($s)); + assertType('int<1, max>', $p); + assertNativeType('mixed', $p); + } + + public function methodCallResult(): void + { + assertType('string', $this->name()); + assertNativeType('string', $this->name()); + } + + public function trackedPropertyNarrowing(): void + { + if (is_int($this->mixedProp)) { + assertType('int', $this->mixedProp); + } + } + + public function mixedNarrowingViaIsFunctions(): void + { + $m = mixedValue(); + if (is_int($m)) { + assertType('int', $m); + } else { + assertType('mixed~int', $m); + } + + $m2 = mixedValue(); + if (is_string($m2)) { + assertType('string', $m2); + } + } + + public function dynamicVariables(string $name): void + { + assertType('*ERROR*', $undefined); + $holder = 1; + assertType('mixed', $$name); + } + + public function unmigratedConditions(string $s, bool $a, bool $b, mixed $m): void + { + if (!$a) { + assertType('false', $a); + } else { + assertType('true', $a); + } + + if ($a && $b) { + assertType('true', $a); + assertType('true', $b); + } + + if ($a || $b) { + assertType('bool', $a); + } + + if ($m instanceof Foo) { + assertType('NewWorldTypeInference\Foo', $m); + } + + if (!empty($s)) { + assertType('non-falsy-string', $s); + } + + $arr = []; + if (rand(0, 1)) { + $arr[] = 'v'; + } + if (isset($arr[0])) { + assertType("array{'v'}", $arr); + } + if (count($arr) > 0) { + assertType("array{'v'}", $arr); + } + } + + public function bareCallStatement(): void + { + $this->name(); + assertType('string', $this->name()); + } + + public function trackedCallExpression(string $s): void + { + $len = strlen($s); + assertType('int<0, max>', strlen($s)); + } + + /** + * @param array $data + */ + public function assertOnUntrackedExpression(array $data): void + { + assert(is_int($data['k'])); + assertType('int', $data['k']); + } + + public function variadicSignatureSelection(int $i): void + { + assertType('int<5, max>', max($i, 5)); + assertType('int', min(1, $i)); + } + + public function echoStatement(string $s): void + { + echo $s; + assertType('string', $s); + } + + public function elseifConditions(int $i): void + { + if ($i > 10) { + assertType('int<11, max>', $i); + } elseif ($i > 5) { + assertType('int<6, 10>', $i); + } else { + assertType('int', $i); + } + } + + public function firstClassCallable(): void + { + $f = strlen(...); + assertType('Closure(string): int<0, max>', $f); + } + + public function listAssignment(): void + { + [$x, $y] = [1, 'a']; + assertType('1', $x); + assertType('\'a\'', $y); + } + + public function closures(): void + { + $fn = function (): int { + return 1; + }; + assertType('1', $fn()); + + $af = static fn (int $z): int => $z + 1; + assertType('int', $af(5)); + } + + public function foreachValueAssignment(): void + { + foreach ([1, 2, 3] as $val) { + assertType('1|2|3', $val); + } + } + + public function dynamicReturnTypeExtensions(mixed $m): void + { + assertType('true', is_int(5)); + assertType('false', is_int('x')); + assertType('bool', is_int($m)); + } + + /** + * intdiv() throw point comes from its DynamicFunctionThrowTypeExtension: + * a possibly-zero divisor throws DivisionByZeroError, a non-zero literal cannot. + */ + public function dynamicThrowTypeExtensions(int $i, int $j): void + { + try { + intdiv($i, $j); + $maybe = 1; + } finally { + assertVariableCertainty(TrinaryLogic::createMaybe(), $maybe); + } + + try { + intdiv($i, 2); + $certain = 1; + } finally { + assertVariableCertainty(TrinaryLogic::createYes(), $certain); + } + } + + public function negatedAndEqualityAsserts(): void + { + $m = mixedValue(); + assertNotInt($m); + assertType('mixed~int', $m); + + $n = mixedValue(); + assertSame5($n); + assertType('5', $n); + } + + private function name(): string + { + return 'x'; + } + + /** @var mixed */ + private $mixedProp; + } function mixedValue(): mixed @@ -140,3 +478,17 @@ function isPositive(int $i): bool { return $i >= 1; } + +/** + * @phpstan-assert !int $value + */ +function assertNotInt(mixed $value): void +{ +} + +/** + * @phpstan-assert =5 $value + */ +function assertSame5(mixed $value): void +{ +} From d0d073316db1ff4477e4d9c8c13cb3eb6658186b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 15:47:40 +0200 Subject: [PATCH 07/50] Resolve FiberScope types at the expression's evaluation point, narrowed by rule-applied filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules narrow their scope after expressions were evaluated — CallMethodsRule filters by a synthetic `$name === 'doFoo'` per possible dynamic method name — and FiberScope::getType() answered from the ExpressionResult's memoized type, ignoring that narrowing (`$this->$name($param)` with name/param correlated via if/else branches reported bogus parameter errors). The answer must also keep the expression's own evaluation-point semantics: in `(new Example)->dump($string1 = 'abc')->dump($string1)` the outer call's visit scope predates the inner assignment, so resolving on the rule's scope is equally wrong. This is what the old fiber design's preprocessScope replay provided. The new-world equivalent: FiberScope accumulates the filterByTruthyValue/ filterByFalseyValue conditions applied since the node visit and getType() replays them onto the result's own scope, resolving there via the new ExpressionResult::getTypeOnScope() — per-scope evaluation where tracked conditional-holder narrowing applies, run on the plain scope variant so the legacy bridges cannot suspend on the same expression again. Also fixes filterByFalseyValue delegating to parent::filterByTruthyValue (copy-paste). --- src/Analyser/ExpressionResult.php | 13 ++++++ src/Analyser/Fiber/FiberScope.php | 74 ++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index c064816a82..7624ce8437 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -153,6 +153,19 @@ public function getTypeForScope(MutatingScope $scope): Type return $this->getType(); } + /** + * Resolves the type on the given scope, honoring narrowing applied to it + * *after* this expression was evaluated — rules filter their scope by a + * synthetic condition and then ask for types (e.g. a dynamic method call + * narrowed by each possible method name). Unlike `getTypeForScope()`, + * nothing is memoized, and resolution runs on the plain variant of the + * scope so the legacy bridges cannot suspend on this expression again. + */ + public function getTypeOnScope(MutatingScope $scope): Type + { + return TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($scope->toMutatingScope())); + } + public function hasTypeCallback(): bool { return $this->typeCallback !== null && $this->expr !== null; diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index fee3fd8af9..2f282ac4b7 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -12,12 +12,19 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; -use function array_pop; final class FiberScope extends MutatingScope { - /** @var Expr[] */ + /** + * Conditions this scope was filtered by *after* the node visit (rules call + * `filterByTruthyValue` with synthetic conditions — e.g. one per possible + * dynamic method name). Replayed onto each ExpressionResult's own scope in + * getType(): the answer keeps the expression's evaluation-point semantics + * and honors the rule's narrowing. + * + * @var Expr[] + */ private array $truthyValueExprs = []; /** @var Expr[] */ @@ -80,13 +87,27 @@ public function doNotTreatPhpDocTypesAsCertain(): Scope throw new ShouldNotHappenException(); } - return $scope->toFiberScope(); + $fiberScope = $scope->toFiberScope(); + $fiberScope->truthyValueExprs = $this->truthyValueExprs; + $fiberScope->falseyValueExprs = $this->falseyValueExprs; + + return $fiberScope; } - /** @api */ + /** + * The type at the expression's own evaluation point, narrowed by the + * conditions this scope was filtered by since the node visit. + * + * @api + */ public function getType(Expr $node): Type { - return $this->getExpressionResult($node)->getTypeForScope($this); + $result = $this->getExpressionResult($node); + if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { + return $result->getTypeForScope($this); + } + + return $result->getTypeOnScope($this->filterByValueExprs($result->getScope())); } public function getScopeType(Expr $expr): Type @@ -102,13 +123,45 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - return $this->getExpressionResult($expr)->getNativeType(); + $result = $this->getExpressionResult($expr); + if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { + return $result->getNativeType(); + } + + $promotedScope = $this->filterByValueExprs($result->getScope())->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $result->getTypeOnScope($promotedScope); } public function getKeepVoidType(Expr $node): Type { // keepVoid is a one-off we will solve separately; fall back to the regular type for now. - return $this->getExpressionResult($node)->getTypeForScope($this); + $result = $this->getExpressionResult($node); + if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { + return $result->getTypeForScope($this); + } + + return $result->getTypeOnScope($this->filterByValueExprs($result->getScope())); + } + + /** + * Replays the rule-applied filters onto the given (plain) scope — the + * filtering runs through the guarded old-world machinery (PHPSTAN_FNSR=0) + * until narrowing by arbitrary synthetic conditions migrates. + */ + private function filterByValueExprs(MutatingScope $scope): MutatingScope + { + foreach ($this->truthyValueExprs as $expr) { + $scope = $scope->filterByTruthyValue($expr); + } + foreach ($this->falseyValueExprs as $expr) { + $scope = $scope->filterByFalseyValue($expr); + } + + return $scope; } public function filterByTruthyValue(Expr $expr): self @@ -117,6 +170,7 @@ public function filterByTruthyValue(Expr $expr): self $scope = parent::filterByTruthyValue($expr); $scope->truthyValueExprs = $this->truthyValueExprs; $scope->truthyValueExprs[] = $expr; + $scope->falseyValueExprs = $this->falseyValueExprs; return $scope; } @@ -124,7 +178,8 @@ public function filterByTruthyValue(Expr $expr): self public function filterByFalseyValue(Expr $expr): self { /** @var self $scope */ - $scope = parent::filterByTruthyValue($expr); + $scope = parent::filterByFalseyValue($expr); + $scope->truthyValueExprs = $this->truthyValueExprs; $scope->falseyValueExprs = $this->falseyValueExprs; $scope->falseyValueExprs[] = $expr; @@ -146,9 +201,6 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, public function popInFunctionCall(): self { - $stack = $this->inFunctionCallsStack; - array_pop($stack); - /** @var self $scope */ $scope = parent::popInFunctionCall(); $scope->truthyValueExprs = $this->truthyValueExprs; From cd20b021f122b82d1425e4149083500ac6d88310 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 15:57:46 +0200 Subject: [PATCH 08/50] Bridge per-scalar holder narrowing through old-world equality and keepVoid through the old world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-world per-scalar conditional-holder block constructed SpecifiedTypes directly with only the assigned expression's entry. Equality narrowing produces more: `$clazz?->foo !== null` pins $clazz non-null and gives the shortcircuited $clazz->foo its own key — without those holders, `$result = $clazz?->foo; if ($result !== null)` no longer narrowed $clazz (bug-6120). Guarded old-world bridge until the equality migration. FiberScope::getKeepVoidType() falling back to the regular type silently lost the void — regular results store void as null, so "Result of method (void) is used" stopped being reported. Guarded old-world bridge too. --- src/Analyser/ExprHandler/AssignHandler.php | 23 +++++++++------------- src/Analyser/Fiber/FiberScope.php | 11 ++++------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index b18636a344..cfcac729e3 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -626,23 +626,18 @@ public function processAssignVar( $astNode = new Node\Expr\Array_($falseyScalar); } - if (NewWorld::isEnabled()) { - // `$assignedExpr !== ` / `=== ` narrowing, - // constructed directly (equality on a constant scalar removes/pins its type) - $notIdenticalSpecifiedTypes = new SpecifiedTypes(sureNotTypes: [$assignedExprString => [$assignedExpr, $falseyType]]); - } else { - $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - } + // `$assignedExpr !== ` / `=== ` narrowing. + // Equality produces entries beyond the assigned expression itself + // (a nullsafe value pins its subject non-null, the shortcircuited + // variant gets its own key) — guarded old-world bridge until the + // equality migration (PHPSTAN_FNSR=0) + $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); + $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); - if (NewWorld::isEnabled()) { - $identicalSpecifiedTypes = new SpecifiedTypes([$assignedExprString => [$assignedExpr, $falseyType]], []); - } else { - $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - } + $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); + $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); } diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 2f282ac4b7..b3f010108d 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -138,13 +138,10 @@ public function getNativeType(Expr $expr): Type public function getKeepVoidType(Expr $node): Type { - // keepVoid is a one-off we will solve separately; fall back to the regular type for now. - $result = $this->getExpressionResult($node); - if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { - return $result->getTypeForScope($this); - } - - return $result->getTypeOnScope($this->filterByValueExprs($result->getScope())); + // keepVoid is a one-off we will solve separately — regular results store + // void as null, so falling back to them would silently lose the void. + // Guarded old-world bridge until then (PHPSTAN_FNSR=0). + return $this->toMutatingScope()->getKeepVoidType($node); } /** From 45532d4683f45958e60594faf5ac6c65b43ffd9b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:08:37 +0200 Subject: [PATCH 09/50] Price function-call extension reads at the call point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic return type extensions ask for argument types through the ResultAwareScope adapter, which wrapped whatever scope the type callback received — for memoized asks that is the result's post-call scope, where the call's own virtual mutations already applied. array_shift($this->container) inside `if ($this->container !== [])` typed as string|null because the extension saw the already-shifted (possibly-empty) array. The adapter now wraps the scope captured right after the arguments were processed, before the call's own effects — matching where the old world priced extension reads. --- src/Analyser/ExprHandler/FuncCallHandler.php | 35 ++++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index f200e7df89..d4d48d24d8 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -293,6 +293,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeBeforeArgs = $scope; $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); $scope = $argsResult->getScope(); + // extensions price arguments at the call point — before the call's own + // virtual mutations (array_shift's shifted arg, invalidations) hit the scope + $scopeAfterArgs = $scope; $hasYield = $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); @@ -337,7 +340,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($functionReflection !== null) { $normalizedExprForThrowPoint = $normalizedExpr; - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage)); + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs), $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage)); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -590,12 +593,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } - $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $storage, $nameResult): Type { + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $scopeAfterArgs): Type { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs); }; return new ExpressionResult( @@ -608,12 +611,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $typeCallback): SpecifiedTypes { + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $typeCallback, $scopeAfterArgs): SpecifiedTypes { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, static fn (): Type => $typeCallback($e, $s), $nodeScopeResolver, $stmt, $storage); + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, static fn (): Type => $typeCallback($e, $s), $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs); }, expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); @@ -642,19 +645,19 @@ private function createAdapterScope( throwPoints: [], impurePoints: [], expr: $expr, - typeCallback: function (Expr $e, MutatingScope $s) use ($nameResult, $nodeScopeResolver, $stmt, $storage): Type { + typeCallback: function (Expr $e, MutatingScope $s) use ($nameResult, $nodeScopeResolver, $stmt, $storage, $scope): Type { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope); }, - specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nameResult, $nodeScopeResolver, $stmt, $storage): SpecifiedTypes { + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nameResult, $nodeScopeResolver, $stmt, $storage, $scope): SpecifiedTypes { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, fn (): Type => $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage), $nodeScopeResolver, $stmt, $storage); + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, fn (): Type => $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope), $nodeScopeResolver, $stmt, $storage, $scope); }, ); @@ -678,9 +681,18 @@ private function resolveTypeViaResults( NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage, + MutatingScope $callSiteScope, ): Type { - $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); + $adapterBase = $callSiteScope; + if ($scope->nativeTypesPromoted) { + $promotedCallSiteScope = $callSiteScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedCallSiteScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $adapterBase = $promotedCallSiteScope; + } + $adapterScope = $this->createAdapterScope($expr, $adapterBase, $nameResult, $nodeScopeResolver, $stmt, $storage); if ($expr->name instanceof Expr) { if ($nameResult === null) { @@ -808,6 +820,7 @@ private function specifyTypesViaResults( NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage, + MutatingScope $callSiteScope, ): SpecifiedTypes { if (!$expr->name instanceof Name) { @@ -815,7 +828,7 @@ private function specifyTypesViaResults( return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); } - $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); + $adapterScope = $this->createAdapterScope($expr, $callSiteScope, $nameResult, $nodeScopeResolver, $stmt, $storage); if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { // lazy create parametersAcceptor, as creation can be expensive From 63042303976e369d390d77970a76ab66cc4b4c87 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:16:45 +0200 Subject: [PATCH 10/50] Flush pending fibers before building the class aggregate nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClassStatementsGatherer collects each node after the inner callback ran, and rules in that callback suspend — their parked fibers defer the collection. ClassPropertiesNode/ClassMethodsNode/ClassConstantsNode snapshot the gathered arrays right after the member list, so they saw incomplete data (a private method called as self::test() inside another method was reported unused). The class statement joins file/function/method/trait as a flush boundary. --- src/Analyser/NodeScopeResolver.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3fd6ae7b4f..e6b8a4c56d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1217,6 +1217,10 @@ public function processStmtNode( }); $this->processStmtNodesInternal($stmt, $classLikeStatements, $classScope, $storage, $classStatementsGatherer, $context); + // the gatherer collects each node after the inner callback (rules) ran — + // suspended rule fibers defer those collections, so they must all + // complete before the aggregate nodes below snapshot the gathered data + $this->processPendingFibers($storage); $this->callNodeCallback($nodeCallback, new ClassPropertiesNode($stmt, $this->readWritePropertiesExtensionProvider, $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls(), $classStatementsGatherer->getReturnStatementsNodes(), $classStatementsGatherer->getPropertyAssigns(), $classReflection), $classScope, $storage); $this->callNodeCallback($nodeCallback, new ClassMethodsNode($stmt, $classStatementsGatherer->getMethods(), $classStatementsGatherer->getMethodCalls(), $classReflection), $classScope, $storage); $this->callNodeCallback($nodeCallback, new ClassConstantsNode($stmt, $classStatementsGatherer->getConstants(), $classStatementsGatherer->getConstantFetches(), $classReflection), $classScope, $storage); From c56069ac0c35ffeea2cbb460b769a33929da5f28 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:27:16 +0200 Subject: [PATCH 11/50] Collect aggregate data before forwarding nodes to rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function/method/closure return-statement collectors and ClassStatementsGatherer forwarded each node to the inner callback (rules) first and collected after. Rules suspend their fiber, deferring the collection past the point where FunctionReturnStatementsNode/ MethodReturnStatementsNode/ClosureReturnStatementsNode snapshot the gathered arrays — execution ends and return statements went missing from the aggregates (@param-out "never assigns" false positives, missing reads in class aggregates). Collection is a pure append and cannot suspend, so it now runs before the forward. --- src/Analyser/NodeScopeResolver.php | 169 +++++++++++++-------------- src/Node/ClassStatementsGatherer.php | 5 +- 2 files changed, 82 insertions(+), 92 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e6b8a4c56d..92edb83a6d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -801,35 +801,31 @@ public function processStmtNode( $executionEnds = []; $functionImpurePoints = []; $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $functionScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $functionImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // FunctionReturnStatementsNode below snapshots these arrays + if ($scope->getFunction() === $functionScope->getFunction() && !$scope->isInAnonymousFunction()) { + if ($node instanceof PropertyAssignNode) { + $functionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } + } } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }, StatementContext::createTopLevel())->toPublic(); $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( @@ -950,44 +946,38 @@ public function processStmtNode( $executionEnds = []; $methodImpurePoints = []; $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $methodScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - if ( - $node->getPropertyFetch() instanceof Expr\PropertyFetch - && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection - && $scope->getFunction()->getDeclaringClass()->hasConstructor() - && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null - ) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // MethodReturnStatementsNode below snapshots these arrays + if ($scope->getFunction() === $methodScope->getFunction() && !$scope->isInAnonymousFunction()) { + if ($node instanceof PropertyAssignNode) { + $isThisConstructorPropertyAssign = $node->getPropertyFetch() instanceof Expr\PropertyFetch + && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection + && $scope->getFunction()->getDeclaringClass()->hasConstructor() + && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() + && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null; + if (!$isThisConstructorPropertyAssign) { + $methodImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + } + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } } - $methodImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }, StatementContext::createTopLevel())->toPublic(); $methodReflection = $methodScope->getFunction(); @@ -3011,37 +3001,34 @@ public function processClosureNode( $closureImpurePoints = []; $invalidateExpressions = []; $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { - $nodeCallback($node, $scope); - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // ClosureReturnStatementsNode below snapshots these arrays + if ($scope->getAnonymousFunctionReflection() === $closureScope->getAnonymousFunctionReflection()) { + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } elseif ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } + } } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }; if (count($byRefUses) === 0) { diff --git a/src/Node/ClassStatementsGatherer.php b/src/Node/ClassStatementsGatherer.php index e2b278fb5b..12c10fb243 100644 --- a/src/Node/ClassStatementsGatherer.php +++ b/src/Node/ClassStatementsGatherer.php @@ -140,9 +140,12 @@ public function getPropertyAssigns(): array public function __invoke(Node $node, Scope $scope): void { + // gather before forwarding: the inner callback (rules) may suspend the + // fiber, deferring the collection past the point where the class + // aggregate nodes snapshot the gathered data + $this->gatherNodes($node, $scope); $nodeCallback = $this->nodeCallback; $nodeCallback($node, $scope); - $this->gatherNodes($node, $scope); } private function gatherNodes(Node $node, Scope $scope): void From 1f3db751ddf82ccde9e2bb2d26330932d990ae63 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:39:35 +0200 Subject: [PATCH 12/50] Promote the call-point adapter for native-type asks in the specify path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call-point pricing fix promoted the adapter base in resolveTypeViaResults but not in specifyTypesViaResults — native-type narrowing then saw PHPDoc types, so the "type is coming from a PHPDoc" tip disappeared from impossible-check errors (the native answer falsely matched the certain one). --- src/Analyser/ExprHandler/FuncCallHandler.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d4d48d24d8..118ed70bbc 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -828,7 +828,15 @@ private function specifyTypesViaResults( return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); } - $adapterScope = $this->createAdapterScope($expr, $callSiteScope, $nameResult, $nodeScopeResolver, $stmt, $storage); + $adapterBase = $callSiteScope; + if ($scope->nativeTypesPromoted) { + $promotedCallSiteScope = $callSiteScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedCallSiteScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $adapterBase = $promotedCallSiteScope; + } + $adapterScope = $this->createAdapterScope($expr, $adapterBase, $nameResult, $nodeScopeResolver, $stmt, $storage); if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { // lazy create parametersAcceptor, as creation can be expensive From 13ce310eff33f9ddbaa05ff1b128580a79ac0fd7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:41:38 +0200 Subject: [PATCH 13/50] Build Ternary/Match conditional-expression holders in mixed mode The blocks were gated old-world-only from the era when the corpus had to stay green with the guard exceptions active; in mixed mode they were skipped entirely, losing variable-certainty correlations like `$mode = isset($x) ? "remove" : "add"` implying the existence of $x from the value of $mode. Their internals are guarded old-world bridges, consistent with the unmigrated TernaryHandler/MatchHandler state. --- src/Analyser/ExprHandler/AssignHandler.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index cfcac729e3..e0c9fb34f7 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -519,9 +519,10 @@ public function processAssignVar( : $scopeBeforeAssignEval->getType($assignedExpr); // Ternary/Match conditional-expression holders need the branch types from - // narrowed scopes — old world only until TernaryHandler/MatchHandler migrate + // narrowed scopes — guarded old-world bridges until TernaryHandler/ + // MatchHandler migrate (PHPSTAN_FNSR=0) $conditionalExpressions = []; - if (!NewWorld::isEnabled() && $assignedExpr instanceof Ternary) { + if ($assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { $if = $assignedExpr->cond; @@ -545,7 +546,7 @@ public function processAssignVar( } } - if (!NewWorld::isEnabled() && $assignedExpr instanceof Match_) { + if ($assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), From 186cccdff77592fee328383a90b20ae2dab1471b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:48:41 +0200 Subject: [PATCH 14/50] Use Yes-certainty holders only as narrowing originals in applySpecifiedTypes A Maybe-certainty holder carries the variable's "when defined" type; using it as the original for sure-not math turned `if ($a)` on a maybe-defined mixed variable into never (falsy-union minus falsy). The original must match getType() semantics, so Maybe holders fall through to the guarded bridge. This also ran under PHPSTAN_FNSR=0 (the engine picks the apply path whenever the result carries callbacks), breaking old-world parity on bug-pr-339. --- src/Analyser/MutatingScope.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index d2aa81df6e..6cdf7d3b45 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3557,7 +3557,13 @@ private function resolveOriginalTypesForApply(Expr $expr, array $exprResults): a if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { $exprString = $this->getNodeKey($expr); - if (array_key_exists($exprString, $this->expressionTypes)) { + if ( + array_key_exists($exprString, $this->expressionTypes) + // a Maybe-certainty holder carries the "when defined" type only; + // the original for narrowing math must match getType() semantics + // (a maybe-defined variable is still mixed) + && $this->expressionTypes[$exprString]->getCertainty()->yes() + ) { $nativeHolder = $this->nativeExpressionTypes[$exprString] ?? $this->expressionTypes[$exprString]; return [ From 7a28e953f04a240a7d338f683571fdb66c718d84 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 16:59:56 +0200 Subject: [PATCH 15/50] Record the whole-suite burn-down leg in the status log --- NEW_WORLD.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index a460f3bdb4..2083cb326d 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -426,6 +426,30 @@ as factual comments at their call sites, not here. `ExpressionTypeResolverExtension` tiers (no such extension in test config), and future-leg provisions (isset-certainty apply branch, TruthyFalsey context, nullsafe roots in migrated specify callbacks). +- 2026-06-11 (whole-suite burn-down): **the full test suite finishes again** (the + hang was the premature pending-fiber flush poisoning stored results) and the + scoreboard is now measured suite-wide: 12843 tests, 25 -> ~10 failures. + Fixed, each with its own commit: FiberScope types resolve at the expression's + evaluation point narrowed by rule-applied filters (restores the old + preprocessScope contract; fixes dynamic-call name/param correlation and + chained-call asks; also fixes filterByFalseyValue delegating to + filterByTruthyValue); keepVoid bridges to the old world (regular results + store void as null — "(void) is used" errors were lost); per-scalar + conditional holders bridge through old-world equality (nullsafe subjects pin + non-null); function-call extension reads price at the call point (before the + call's own virtual mutations — array_shift saw the already-shifted arg); + native-type promotion mirrored into the specify-path adapter (PHPDoc tips + were lost); collectors collect before forwarding to rules (suspended rule + fibers deferred execution-end/return collection past the aggregate-node + snapshots) plus a class-boundary fiber flush before the Class*Nodes; + Ternary/Match conditional holders un-gated into mixed mode (isset-ternary + variable certainty); applySpecifiedTypes uses Yes-certainty holders only as + narrowing originals (Maybe holders carry "when defined" types — broke + FNSR=0 parity, found by bisect). Remaining failures are one designed-fix + family (passed-closure typing context through the adapter — see the task + notes; three heuristic attempts each traded fixes for breaks) + the + multi-assign precision improvement awaiting a mode-dependent-expectations + policy. - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker From a4c56962960657c3d32e8d0859f53f43fc6d10b7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 17:53:48 +0200 Subject: [PATCH 16/50] Type-free default narrowing and acyclic result graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural changes proven by the NodeScopeResolverTest slowdown hunt (92.8s vs 21.5s on 2.2.x at equal peak memory; per-test times degraded 19→79ms across the run while the base stayed flat; with GC disabled the rewrite ran in 24.4s — the entire 4.3x was cyclic-GC scans over live webs): 1. DefaultNarrowingHelper::specifyDefaultTypes() loses the expression type. It only needed it for nullsafe short-circuiting, and single-pass analysis does not need that: expressions process inside-out, so only the two nullsafe handlers ever see a `?->` — they will emit the plain-chain variant alongside their own key once, and parents compose their results. No recursive chain-walking, and specifyTypesCallbacks no longer invoke the typeCallback (repeatedly computing types just to narrow). 2. FuncCall result callbacks no longer capture the ExpressionResultStorage the result lives in — every stored call result was a reference cycle, and one call anywhere in an expression made the whole ancestor result graph collectable only by the cyclic GC. Late asks build their adapters on a fresh storage; the synthetics-in-flight cycle guard threads through it. NodeScopeResolverTest: 92.8s -> 25.5s (2.2.x base: 21.5s), same failures. --- NEW_WORLD.md | 17 ++++++++++ src/Analyser/ExprHandler/ArrayHandler.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 4 +-- src/Analyser/ExprHandler/FuncCallHandler.php | 20 ++++++----- .../Helper/DefaultNarrowingHelper.php | 32 ++++++----------- src/Analyser/ExprHandler/PostDecHandler.php | 2 +- src/Analyser/ExprHandler/PostIncHandler.php | 2 +- src/Analyser/ExprHandler/PreDecHandler.php | 2 +- src/Analyser/ExprHandler/PreIncHandler.php | 2 +- src/Analyser/ExprHandler/ScalarHandler.php | 2 +- src/Analyser/ExprHandler/VariableHandler.php | 2 +- .../Virtual/NativeTypeExprHandler.php | 2 +- .../ExprHandler/Virtual/TypeExprHandler.php | 2 +- tests/PHPStan/Analyser/data/new-world.php | 34 +++++++++++++++++++ 14 files changed, 84 insertions(+), 41 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 2083cb326d..4112fabf36 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -122,6 +122,23 @@ deliverable at every point along the way. holder → known-results map → inline re-process (`processExprNode` on a duplicated storage with `NoopNodeCallback` — handles the synthetic exprs extensions love to build) → guarded bridge. +10. **Single-pass analysis kills nullsafe short-circuiting.** The old world walks every + eligible expression recursively (`NullsafeShortCircuitingHelper`, + `NullsafeOperatorHelper::getNullsafeShortcircuitedExpr`) to find a `?->` somewhere in + the chain that influences the result. In the new world expressions process inside-out, + so only `NullsafePropertyFetchHandler` and `NullsafeMethodCallHandler` ever see the + `?->` — they emit the plain-chain variant alongside their own key **once**, and every + parent composes their results. `DefaultNarrowingHelper::specifyDefaultTypes()` therefore + needs no expression type at all, and `specifyTypesCallback`s never invoke the + `typeCallback` — narrowing callbacks are cheap, type-free closures. +11. **Result callbacks must not capture the `ExpressionResultStorage`.** Stored results + capturing the storage they live in are reference cycles only the cyclic GC can free; + one call anywhere in an expression makes the whole ancestor result graph cyclic. + Measured: the cycles were the *entire* 4.3× `NodeScopeResolverTest` slowdown (92s → 25s + when broken; the engine work itself was at old-world parity all along, the time went to + GC scans over live cyclic webs). Late asks build their adapters on a **fresh storage** + instead — the synthetics-in-flight cycle guard threads through it, only known-result + seeding is lost on those rare paths. ## 4. What we gain diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 620ce76c00..e879352636 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -154,7 +154,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index e0c9fb34f7..f88f2ff3e9 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -438,7 +438,7 @@ private function specifyTypesForAssign(Assign|AssignRef $expr, MutatingScope $sc { if ($expr instanceof AssignRef && $assignedExprResult !== null) { // the old world treats by-reference assignments with default narrowing - return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $assignedExprResult->getTypeForScope($scope), $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } if ( @@ -466,7 +466,7 @@ private function specifyTypesForAssign(Assign|AssignRef $expr, MutatingScope $sc } if ($expr->var instanceof Variable && is_string($expr->var->name)) { - return $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $assignedExprResult->getTypeForScope($scope), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); } // guarded legacy bridge diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 118ed70bbc..537fa15c91 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -593,12 +593,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } - $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $scopeAfterArgs): Type { + // the result lives in $storage — capturing it would make every stored + // FuncCall result a reference cycle only the cyclic GC can free, and one + // call anywhere in an expression makes the whole ancestor graph cyclic + // (measured as the entire 4x analysis slowdown). Late asks build their + // adapter on a fresh storage instead: the synthetics-in-flight cycle + // guard threads through it, only known-result seeding is lost + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $nameResult, $scopeAfterArgs): Type { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs); + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, new ExpressionResultStorage(), $scopeAfterArgs); }; return new ExpressionResult( @@ -611,12 +617,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $storage, $nameResult, $typeCallback, $scopeAfterArgs): SpecifiedTypes { + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $nameResult, $scopeAfterArgs): SpecifiedTypes { if (!$e instanceof FuncCall) { throw new ShouldNotHappenException(); } - return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, static fn (): Type => $typeCallback($e, $s), $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs); + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, $nodeScopeResolver, $stmt, new ExpressionResultStorage(), $scopeAfterArgs); }, expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); @@ -657,7 +663,7 @@ private function createAdapterScope( throw new ShouldNotHappenException(); } - return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, fn (): Type => $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope), $nodeScopeResolver, $stmt, $storage, $scope); + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope); }, ); @@ -809,14 +815,12 @@ private function resolveTypeViaResults( * narrowing still delegate to TypeSpecifier helpers (with the adapter) — * to be ported before the old world is deleted. * - * @param callable(): Type $ownTypeCallback */ private function specifyTypesViaResults( FuncCall $expr, MutatingScope $scope, TypeSpecifierContext $context, ?ExpressionResult $nameResult, - callable $ownTypeCallback, NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage, @@ -887,7 +891,7 @@ private function specifyTypesViaResults( return (new SpecifiedTypes([], []))->setRootExpr($expr); } - return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $ownTypeCallback(), $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } return (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index 61ff8bc3f9..aa2ddc1939 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -3,20 +3,21 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use PhpParser\Node\Expr; -use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\StaticTypeFactory; -use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; /** * New-world replacement for TypeSpecifier::handleDefaultTruthyOrFalseyContext(): - * the default narrowing of an expression used in a boolean context, computed - * from the expression's own type (known from its ExpressionResult) instead of - * Scope::getType(). + * the default narrowing of an expression used in a boolean context. + * + * Unlike the old world there is no nullsafe short-circuiting here: expressions + * process inside-out, so only NullsafePropertyFetchHandler and + * NullsafeMethodCallHandler ever see a `?->` — they emit the plain-chain + * variant alongside their own key once, and every parent simply composes + * their results. No recursive chain-walking, no type ask. */ #[AutowiredService] final class DefaultNarrowingHelper @@ -26,7 +27,7 @@ public function __construct(private ExprPrinter $exprPrinter) { } - public function specifyDefaultTypes(Expr $expr, Type $exprType, TypeSpecifierContext $context): SpecifiedTypes + public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes { if ($context->null()) { return (new SpecifiedTypes([], []))->setRootExpr($expr); @@ -40,22 +41,9 @@ public function specifyDefaultTypes(Expr $expr, Type $exprType, TypeSpecifierCon return (new SpecifiedTypes([], []))->setRootExpr($expr); } - // mirrors TypeSpecifier::createForExpr() in createFalse() context - $containsNull = !TypeCombinator::containsNull($removedType) && !$exprType->isNull()->no(); - - $originalExpr = $expr; - if (!$containsNull) { - $expr = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($expr); - } - - $sureNotTypes = [ + return (new SpecifiedTypes(sureNotTypes: [ $this->exprPrinter->printExpr($expr) => [$expr, $removedType], - ]; - if ($expr !== $originalExpr) { - $sureNotTypes[$this->exprPrinter->printExpr($originalExpr)] = [$originalExpr, $removedType]; - } - - return (new SpecifiedTypes(sureNotTypes: $sureNotTypes))->setRootExpr($originalExpr); + ]))->setRootExpr($expr); } } diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index d6891a56f7..2582a2a26e 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -71,7 +71,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 7c9cc24404..07cd49b7f3 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -71,7 +71,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index a9dcec9431..aba8237b63 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -146,7 +146,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 45916b3b2d..2d51c7cc4f 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -147,7 +147,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $varResult->getImpurePoints(), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 44fdf3a0ca..871097fb8a 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -55,7 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 9a656c4a1c..2dd7e293d2 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -128,7 +128,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 169ca2851c..9aa1d45793 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -63,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 07e98a5330..6d293acbae 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -59,7 +59,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: [], expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $typeCallback($e, $s), $ctx), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index d97e9bfd3e..1814fd297a 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -252,6 +252,31 @@ public function postIncInCondition(int $i): void } } + /** + * Nullsafe short-circuiting: a truthy `$bar?->...` implies the subject is + * non-null and the plain-chain variant is narrowed too. In the new world + * this knowledge lives in the nullsafe handlers alone (they process first, + * parents compose their results) — no parent re-derives it from types. + */ + public function nullsafeShortCircuiting(?Holder $holder): void + { + if ($holder?->count) { + assertType('NewWorldTypeInference\Holder', $holder); + assertType('int|int<1, max>', $holder->count); + } + + if ($holder?->name !== null) { + assertType('NewWorldTypeInference\Holder', $holder); + assertType('string', $holder->name); + } + + // nullsafe embedded under a migrated handler's narrowing: the FuncCall's + // conditional return narrows its argument, the apply side narrows $holder + if (strlen((string) $holder?->name) > 0) { + assertType('non-empty-string', (string) $holder?->name); + } + } + /** * @param positive-int $p */ @@ -459,6 +484,15 @@ private function name(): string } +class Holder +{ + + public int $count = 0; + + public string $name = ''; + +} + function mixedValue(): mixed { return 1; From 7369daffff63f9456cff333523174b4fcc24bca4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 18:19:52 +0200 Subject: [PATCH 17/50] Migrate PropertyFetchHandler and NullsafePropertyFetchHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nullsafe handler is now the only place that knows about `?->` (NEW_WORLD.md §3.10): it evaluates the subject once, narrows it non-null for the property part through the new type-taking ensureShallowNonNullabilityFromTypes(), shows rules the virtual plain fetch itself (storing a result their asks resolve from), and its narrowing callback emits the plain-chain dual key — one structural getNullsafeShortcircuitedExpr call — plus a subject-not-null entry, replacing the old dispatcher-built `BooleanAnd($var !== null, $var->prop)` recursion. The plain handler propagates a nullsafe var's short-circuit null exactly one level; no recursive chain walking anywhere. ExpressionResult gains companionResults: producers attach results for companion expressions their narrowing touches (the plain variant), and applySpecifiedTypes resolves pre-narrowing types from them. FiberScope::getScopeType()/getScopeNativeType() route through the expression result + filter replay until the dedicated scope-walk design lands. --- NEW_WORLD.md | 22 ++- .../Helper/NonNullabilityHelper.php | 50 ++++++ .../NullsafePropertyFetchHandler.php | 149 ++++++++++++++++-- .../ExprHandler/PropertyFetchHandler.php | 88 ++++++++++- src/Analyser/ExpressionResult.php | 24 ++- src/Analyser/Fiber/FiberScope.php | 9 +- src/Analyser/NodeScopeResolver.php | 2 +- tests/PHPStan/Analyser/data/new-world.php | 47 ++++++ 8 files changed, 369 insertions(+), 22 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 4112fabf36..d4965b7054 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -290,14 +290,14 @@ as factual comments at their call sites, not here. - [ ] MethodCallHandler - [ ] NewHandler - [ ] NullsafeMethodCallHandler -- [ ] NullsafePropertyFetchHandler +- [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge - [ ] PipeHandler - [x] PostDecHandler - [x] PostIncHandler - [x] PreDecHandler - [x] PreIncHandler - [ ] PrintHandler -- [ ] PropertyFetchHandler +- [x] PropertyFetchHandler — one-level short-circuit propagation from a nullsafe var; dynamic names bridge - [x] ScalarHandler - [ ] StaticCallHandler - [ ] StaticPropertyFetchHandler @@ -467,6 +467,24 @@ as factual comments at their call sites, not here. notes; three heuristic attempts each traded fixes for breaks) + the multi-assign precision improvement awaiting a mode-dependent-expectations policy. +- 2026-06-11 (property leg): **PropertyFetchHandler + NullsafePropertyFetchHandler + migrate** — the first leg driven end-to-end by the §5a loop with the + disableOldWorld meter. The nullsafe handler is now the only place that knows + about `?->` (§3.10): it evaluates the subject once, narrows it non-null for + the property part via the new type-taking + `ensureShallowNonNullabilityFromTypes()`, fires the rule callback for the + virtual plain fetch itself and stores a result for it, and its + specifyTypesCallback emits the plain-chain dual key (one structural + `getNullsafeShortcircuitedExpr` call) plus a subject-not-null entry — + replacing the old dispatcher-built `BooleanAnd(var !== null, plain)`. + The plain handler propagates a nullsafe var's short-circuit null one level + (no recursion). `ExpressionResult` gains **companionResults** so + applySpecifiedTypes can price the plain variant's original type from the + stored plain result. `FiberScope::getScopeType/getScopeNativeType` rerouted + through the result path (the reserved scope-walk design pending — flagged). + Leg coverage: 89.5%+ of executable changed lines via 18 new corpus probes + (non-nullable/null/array-dim subjects, chains, dynamic names, native asks, + bare-statement context); the rest are defensive throws and rule-only paths. - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 02e197a28e..f593acfec1 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; #[AutowiredService] @@ -79,6 +80,55 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina ); } + /** + * New-world variant of ensureShallowNonNullability(): the expression's type + * comes from its already-known ExpressionResult instead of Scope::getType(). + * The ArrayDimFetch parent record still reads the parent's type through the + * guarded legacy bridge (PHPSTAN_FNSR=0) until ArrayDimFetchHandler migrates. + */ + public function ensureShallowNonNullabilityFromTypes(MutatingScope $scope, Expr $exprToSpecify, Type $exprType, Type $nativeType): EnsuredNonNullabilityResult + { + $isNull = $exprType->isNull(); + if ($isNull->yes()) { + return new EnsuredNonNullabilityResult($scope, []); + } + + $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); + if ($exprType->equals($exprTypeWithoutNull)) { + return new EnsuredNonNullabilityResult($scope, []); + } + + $specifiedExpressions = []; + if ($exprToSpecify instanceof Expr\ArrayDimFetch && $exprToSpecify->dim !== null) { + $parentExpr = $exprToSpecify->var; + $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( + $parentExpr, + $scope->getType($parentExpr), + $scope->getNativeType($parentExpr), + $scope->hasExpressionType($parentExpr), + ); + } + + $hasExpressionType = $scope->hasExpressionType($exprToSpecify); + $certainty = TrinaryLogic::createYes(); + if (!$hasExpressionType->no()) { + $certainty = $hasExpressionType; + } + + $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); + $scope = $scope->specifyExpressionType( + $exprToSpecify, + $exprTypeWithoutNull, + TypeCombinator::removeNull($nativeType), + TrinaryLogic::createYes(), + ); + + return new EnsuredNonNullabilityResult( + $scope, + $specifiedExpressions, + ); + } + public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult { $specifiedExpressions = []; diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 0c9e190ff4..1482170984 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -8,22 +8,29 @@ use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\NullsafePropertyFetch; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; +use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Php\PhpVersion; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; +use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; @@ -37,6 +44,10 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private PropertyFetchHandler $propertyFetchHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private ExprPrinter $exprPrinter, + private PhpVersion $phpVersion, ) { } @@ -84,25 +95,143 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $varResult->getScope(); + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + + // the only place that ever needs to know about `?->`: the subject was just + // evaluated, narrow it non-null for the property part and revert after — + // parents simply compose this result (NEW_WORLD.md §3.10) + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullabilityFromTypes($scope, $expr->var, $varResult->getType(), $varResult->getNativeType()); + $scope = $nonNullabilityResult->getScope(); + $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode($stmt, new PropertyFetch( - $expr->var, - $expr->name, - $attributes, - ), $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); - $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $plainFetch = new PropertyFetch($expr->var, $expr->name, $attributes); + + $varTypeWithoutNullCallback = static fn (Expr $e, MutatingScope $s): Type => TypeCombinator::removeNull($varResult->getTypeForScope($s)); + + if ($expr->name instanceof Identifier) { + $propertyName = $expr->name->toString(); + $propertyReflection = $scope->getInstancePropertyReflection(TypeCombinator::removeNull($varResult->getType()), $propertyName); + if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { + $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); + if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { + $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scope, $plainFetch, $nativeProperty, 'get')); + } + } + } else { + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $nameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints()); + $scope = $nameResult->getScope(); + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $plainFetch); + } + } + + // rules keep seeing the virtual plain fetch, as the old delegation provided; + // their getType() asks resolve from the stored result below + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, $plainFetch, $scope, $storage, $context); + $plainResult = new ExpressionResult( + $scope, + hasYield: $hasYield, + isAlwaysTerminating: false, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + expr: $plainFetch, + typeCallback: $this->propertyFetchHandler->createTypeCallbackForVarType($varTypeWithoutNullCallback), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), + ); + $nodeScopeResolver->storeResult($storage, $plainFetch, $plainResult); + + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); + + $propertyTypeCallback = $this->propertyFetchHandler->createTypeCallbackForVarType($varTypeWithoutNullCallback); + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $propertyTypeCallback): Type { + if (!$e instanceof NullsafePropertyFetch) { + throw new ShouldNotHappenException(); + } + + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + return new NullType(); + } + + $propertyType = $propertyTypeCallback($e, $s); + if (TypeCombinator::containsNull($varType)) { + return TypeCombinator::union($propertyType, new NullType()); + } + + return $propertyType; + }; return new ExpressionResult( $scope, - hasYield: $exprResult->hasYield(), + hasYield: $hasYield, isAlwaysTerminating: false, - throwPoints: $exprResult->getThrowPoints(), - impurePoints: $exprResult->getImpurePoints(), + throwPoints: $throwPoints, + impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($varResult), + companionResults: [$scope->getNodeKey($plainFetch) => $plainResult], ); } + /** + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(ExpressionResult $varResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult): SpecifiedTypes { + if (!$e instanceof NullsafePropertyFetch) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + if (!$ctx->truthy()) { + $removedType = StaticTypeFactory::truthy(); + $chainExecuted = false; + } elseif (!$ctx->falsey()) { + $removedType = StaticTypeFactory::falsey(); + // a truthy result cannot have come from the short-circuit null + $chainExecuted = true; + } else { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + $sureNotTypes = [ + $this->exprPrinter->printExpr($e) => [$e, $removedType], + ]; + + $varType = $varResult->getTypeForScope($s); + $varCanBeNull = TypeCombinator::containsNull($varType); + + if ($chainExecuted || !$varCanBeNull) { + // the plain-chain variant holds the same narrowing + $plain = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($e); + if ($plain !== $e) { + $sureNotTypes[$this->exprPrinter->printExpr($plain)] = [$plain, $removedType]; + } + } + + if ($chainExecuted && $varCanBeNull) { + // the chain executed, so the subject is not null + $sureNotTypes[$this->exprPrinter->printExpr($e->var)] = [$e->var, new NullType()]; + } + + return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); + }; + } + } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 9be0c5c28b..d789994e03 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; @@ -11,6 +12,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -22,8 +24,10 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Properties\PropertyReflectionFinder; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; @@ -40,6 +44,7 @@ final class PropertyFetchHandler implements ExprHandler public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,7 +56,6 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $scopeBeforeVar = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $varResult->hasYield(); $throwPoints = $varResult->getThrowPoints(); @@ -60,13 +64,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $varResult->getScope(); if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); - $propertyHolderType = $scopeBeforeVar->getType($expr->var); - $propertyReflection = $scopeBeforeVar->getInstancePropertyReflection($propertyHolderType, $propertyName); + $propertyHolderType = $varResult->getType(); + $propertyReflection = $scope->getInstancePropertyReflection($propertyHolderType, $propertyName); if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scopeBeforeVar, $expr, $nativeProperty, 'get')); + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scope, $expr, $nativeProperty, 'get')); } } } else { @@ -89,9 +93,83 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($varResult), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } + /** + * Shared with NullsafePropertyFetchHandler — it passes the var's type with + * null already removed and unions the null back itself. + * + * @param callable(Expr, MutatingScope): Type $varTypeCallback + */ + public function createTypeCallbackForVarType(callable $varTypeCallback): Closure + { + return function (Expr $e, MutatingScope $s) use ($varTypeCallback): Type { + if (!$e instanceof PropertyFetch && !$e instanceof Expr\NullsafePropertyFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof Identifier) { + // dynamic property names: guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $varType = $varTypeCallback($e, $s); + + if ($s->nativeTypesPromoted) { + $propertyReflection = $s->getInstancePropertyReflection($varType, $e->name->name); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + return $propertyReflection->getNativeType(); + } + + $returnType = $this->propertyFetchType($s, $varType, $e->name->name, $e); + + return $returnType ?? new ErrorType(); + }; + } + + private function createTypeCallback(ExpressionResult $varResult): Closure + { + // a nullsafe var that can be null short-circuits this fetch too; its + // handler already produced the null-union — propagate one level, no + // recursive chain walking (NEW_WORLD.md §3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($varResult): bool { + if (!$e instanceof PropertyFetch) { + throw new ShouldNotHappenException(); + } + + return ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varResult->getTypeForScope($s)); + }; + $inner = $this->createTypeCallbackForVarType(static function (Expr $e, MutatingScope $s) use ($varResult, $isShortcircuited): Type { + $varType = $varResult->getTypeForScope($s); + if ($isShortcircuited($e, $s)) { + return TypeCombinator::removeNull($varType); + } + + return $varType; + }); + + return static function (Expr $e, MutatingScope $s) use ($inner, $isShortcircuited): Type { + $type = $inner($e, $s); + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($type, new NullType()); + } + + return $type; + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { @@ -137,7 +215,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new MixedType(); } - private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch $propertyFetch): ?Type + private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch|Expr\NullsafePropertyFetch $propertyFetch): ?Type { $propertyReflection = $scope->getInstancePropertyReflection($fetchedOnType, $propertyName); if ($propertyReflection === null) { diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7624ce8437..07c5583800 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -40,6 +40,10 @@ final class ExpressionResult * @param (callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback + * @param array $companionResults results for companion + * expressions this result's specifyTypesCallback narrows alongside its own + * (the plain-chain variant of a nullsafe fetch) — applySpecifiedTypes + * resolves their pre-narrowing types from here */ public function __construct( private MutatingScope $scope, @@ -53,6 +57,7 @@ public function __construct( ?callable $typeCallback = null, ?callable $specifyTypesCallback = null, private ?ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider = null, + private array $companionResults = [], ) { $this->truthyScopeCallback = $truthyScopeCallback; @@ -198,7 +203,7 @@ public function getTruthyScope(): MutatingScope if ($this->specifyTypesCallback !== null && $this->expr !== null) { return $this->truthyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), - [$this->scope->getNodeKey($this->expr) => $this], + $this->getExprResultsForApply(), ); } @@ -220,7 +225,7 @@ public function getFalseyScope(): MutatingScope if ($this->specifyTypesCallback !== null && $this->expr !== null) { return $this->falseyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), - [$this->scope->getNodeKey($this->expr) => $this], + $this->getExprResultsForApply(), ); } @@ -233,6 +238,21 @@ public function getFalseyScope(): MutatingScope return $this->falseyScope; } + /** + * Self + companions, keyed by node key — the pre-narrowing type sources + * for applySpecifiedTypes(). + * + * @return array + */ + public function getExprResultsForApply(): array + { + if ($this->expr === null) { + throw new ShouldNotHappenException(); + } + + return $this->companionResults + [$this->scope->getNodeKey($this->expr) => $this]; + } + public function isAlwaysTerminating(): bool { return $this->isAlwaysTerminating; diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index b3f010108d..5e6478c77e 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -110,14 +110,19 @@ public function getType(Expr $node): Type return $result->getTypeOnScope($this->filterByValueExprs($result->getScope())); } + /** + * Scope-walk semantics approximated by the expression result + filter replay + * until the dedicated getScopeType design lands — the old walk is the guarded + * legacy path (PHPSTAN_FNSR=0). + */ public function getScopeType(Expr $expr): Type { - return $this->toMutatingScope()->getType($expr); + return $this->getType($expr); } public function getScopeNativeType(Expr $expr): Type { - return $this->toMutatingScope()->getNativeType($expr); + return $this->getNativeType($expr); } /** @api */ diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 92edb83a6d..f93753122a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1127,7 +1127,7 @@ public function processStmtNode( if ($result->hasSpecifiedTypesCallback()) { $scope = $scope->applySpecifiedTypes( $result->getSpecifiedTypes($scope, TypeSpecifierContext::createNull()), - [$scope->getNodeKey($stmt->expr) => $result], + $result->getExprResultsForApply(), ); } else { $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 1814fd297a..8ca2da7005 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -277,6 +277,48 @@ public function nullsafeShortCircuiting(?Holder $holder): void } } + public function nullsafeVariants(Holder $definite, ?Holder $maybe, string $prop): void + { + // non-nullable subject: ?-> behaves like -> (no null union) + assertType('int', $definite?->count); + if ($definite?->count) { + assertType('int|int<1, max>', $definite->count); + } else { + assertType('0', $definite->count); + } + + // null subject: always short-circuits + $nothing = null; + assertType('null', $nothing?->count); + + // chain: the short-circuit null propagates through the plain fetch + assertType('int|null', $maybe?->inner->count); + + // dynamic property names take the legacy bridge + assertType('mixed', $definite->{$prop}); + assertType('mixed', $maybe?->{$prop}); + + // bare statement (null context narrowing) + $maybe?->count; + assertType('NewWorldTypeInference\\Holder|null', $maybe); + } + + /** + * @param array $holders + */ + public function nullsafeOnArrayDimFetch(array $holders): void + { + assertType('int|null', $holders[0]?->count); + } + + public function propertyNativeTypes(Holder $h): void + { + assertNativeType('int', $h->count); + assertNativeType('mixed', $h->untyped); + assertType('*ERROR*', $h->unknownProp); + assertNativeType('*ERROR*', $h->unknownProp); + } + /** * @param positive-int $p */ @@ -491,6 +533,11 @@ class Holder public string $name = ''; + public Holder $inner; + + /** @var mixed */ + public $untyped; + } function mixedValue(): mixed From 7e43926bc8be966bc2694f48b7b6158c4f6adf05 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 18:45:53 +0200 Subject: [PATCH 18/50] Migrate NullsafeMethodCallHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shares the §3.10 nullsafe narrowing callback with the property handler (extracted to DefaultNarrowingHelper), with a purity gate mirroring TypeSpecifier::create(): impure call results are not remembered, so only the subject-not-null entry survives for them. The call part is reused through the new MethodCallHandler::processCallWithVarResult() seam — the subject is evaluated once, narrowed non-null from its result types, and the threaded $calledOnType also de-guards the plain handler's biggest old-world ask. While the virtual plain call is in flight, rules asking about the subject get a narrowed-view result (the old delegation re-evaluated the subject on the narrowed scope; storing the view and restoring the original preserves that contract without the second evaluation) — except for an always-null subject, which stays null so the call is reported. --- NEW_WORLD.md | 2 +- .../Helper/DefaultNarrowingHelper.php | 66 ++++++++++++ .../ExprHandler/MethodCallHandler.php | 26 +++-- .../ExprHandler/NullsafeMethodCallHandler.php | 100 +++++++++++++++--- .../NullsafePropertyFetchHandler.php | 77 ++++---------- tests/PHPStan/Analyser/data/new-world.php | 21 ++++ 6 files changed, 216 insertions(+), 76 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index d4965b7054..d2f135e720 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -289,7 +289,7 @@ as factual comments at their call sites, not here. - [ ] MatchHandler - [ ] MethodCallHandler - [ ] NewHandler -- [ ] NullsafeMethodCallHandler +- [x] NullsafeMethodCallHandler — shares the §3.10 callback; call part reused via MethodCallHandler::processCallWithVarResult; call type bridges until MethodCallHandler migrates; impure calls gate result narrowing - [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge - [ ] PipeHandler - [x] PostDecHandler diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index aa2ddc1939..b214643800 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -3,11 +3,17 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\NullType; use PHPStan\Type\StaticTypeFactory; +use PHPStan\Type\TypeCombinator; /** * New-world replacement for TypeSpecifier::handleDefaultTruthyOrFalseyContext(): @@ -46,4 +52,64 @@ public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): ]))->setRootExpr($expr); } + /** + * The narrowing callback for `?->` expressions, shared by + * NullsafePropertyFetchHandler and NullsafeMethodCallHandler — the only two + * places that know about short-circuiting (NEW_WORLD.md §3.10). Emits the + * plain-chain dual key (one structural getNullsafeShortcircuitedExpr call) + * and, when the chain provably executed, a subject-not-null entry. + * + * @param Expr\NullsafePropertyFetch|Expr\NullsafeMethodCall $expr + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + public function createNullsafeSpecifyCallback(Expr $expr, ExpressionResult $varResult, bool $resultNarrowingAllowed = true): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult, $resultNarrowingAllowed): SpecifiedTypes { + if (!$e instanceof Expr\NullsafePropertyFetch && !$e instanceof Expr\NullsafeMethodCall) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + if (!$ctx->truthy()) { + $removedType = StaticTypeFactory::truthy(); + $chainExecuted = false; + } elseif (!$ctx->falsey()) { + $removedType = StaticTypeFactory::falsey(); + // a truthy result cannot have come from the short-circuit null + $chainExecuted = true; + } else { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // impure calls are not remembered, so narrowing their result is unsound — + // mirrors the call gate in TypeSpecifier::create() (the subject entry below + // stays: the chain executing says nothing about the result's purity) + $sureNotTypes = []; + if ($resultNarrowingAllowed) { + $sureNotTypes[$this->exprPrinter->printExpr($e)] = [$e, $removedType]; + } + + $varType = $varResult->getTypeForScope($s); + $varCanBeNull = TypeCombinator::containsNull($varType); + + if ($resultNarrowingAllowed && ($chainExecuted || !$varCanBeNull)) { + // the plain-chain variant holds the same narrowing + $plain = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($e); + if ($plain !== $e) { + $sureNotTypes[$this->exprPrinter->printExpr($plain)] = [$plain, $removedType]; + } + } + + if ($chainExecuted && $varCanBeNull) { + // the chain executed, so the subject is not null + $sureNotTypes[$this->exprPrinter->printExpr($e->var)] = [$e->var, new NullType()]; + } + + return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); + }; + } + } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 77f5969ae7..48aa1ffefd 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; @@ -85,17 +86,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $closureCallScope ?? $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $varResult->hasYield(); - $throwPoints = $varResult->getThrowPoints(); - $impurePoints = $varResult->getImpurePoints(); - $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $scope = $varResult->getScope(); if (isset($closureCallScope)) { $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); } + + return $this->processCallWithVarResult($nodeScopeResolver, $stmt, $expr, $varResult, $varResult->getType(), $scope, $storage, $nodeCallback, $context); + } + + /** + * The call part after the var was evaluated — NullsafeMethodCallHandler + * reuses it with the subject narrowed non-null and the null stripped from + * $calledOnType, avoiding a second evaluation of the var. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, MethodCall $expr, ExpressionResult $varResult, Type $calledOnType, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $parametersAcceptor = null; $methodReflection = null; - $calledOnType = $scope->getType($expr->var); if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); @@ -201,12 +214,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); - $calledOnType = $originalScope->getType($expr->var); if (!$expr->name instanceof Identifier) { return $result; } $methodName = $expr->name->name; - $methodReflection = $originalScope->getMethodReflection($calledOnType, $methodName); + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); if ($methodReflection === null) { return $result; } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 864a485927..4e2bdb8b83 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\BinaryOp\NotIdentical; @@ -14,6 +15,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -21,8 +23,10 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -37,6 +41,10 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private MethodCallHandler $methodCallHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + #[AutowiredParameter] + private bool $rememberPossiblyImpureFunctionValues, ) { } @@ -85,25 +93,58 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $scopeBeforeNullsafe = $scope; - $varType = $scope->getType($expr->var); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $varResult->getScope(); + $varType = $varResult->getType(); + + // the only place that ever needs to know about `?->`: the subject was just + // evaluated, narrow it non-null for the call part and revert after — + // parents simply compose this result (NEW_WORLD.md §3.10) + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullabilityFromTypes($scope, $expr->var, $varType, $varResult->getNativeType()); + $scope = $nonNullabilityResult->getScope(); - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode( + $plainCall = new MethodCall($expr->var, $expr->name, $expr->args, $attributes); + + // rules see the virtual plain call as the old delegation provided, and their + // asks about the subject must answer the narrowed type while the call part is + // in flight — the old world re-evaluated the subject on the narrowed scope + $narrowedVarResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr->var, + typeCallback: static function (Expr $e, MutatingScope $s) use ($varResult): Type { + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + // an always-null subject is not narrowed (the call is reported instead) + return $varType; + } + + return TypeCombinator::removeNull($varType); + }, + ); + $nodeScopeResolver->storeResult($storage, $expr->var, $narrowedVarResult); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, $plainCall, $scope, $storage, $context); + $plainResult = $this->methodCallHandler->processCallWithVarResult( + $nodeScopeResolver, $stmt, - new MethodCall( - $expr->var, - $expr->name, - $expr->args, - $attributes, - ), - $nonNullabilityResult->getScope(), + $plainCall, + $varResult, + TypeCombinator::removeNull($varType), + $scope, $storage, $nodeCallback, $context, ); - $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $plainResult->setExpr($plainCall); + $nodeScopeResolver->storeResult($storage, $plainCall, $plainResult); + $nodeScopeResolver->storeResult($storage, $expr->var, $varResult); + + $scope = $this->nonNullabilityHelper->revertNonNullability($plainResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $varIsNull = $varType->isNull(); if ($varIsNull->yes()) { @@ -115,14 +156,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } + $methodReflection = $expr->name instanceof Node\Identifier + ? $scope->getMethodReflection(TypeCombinator::removeNull($varType), $expr->name->toString()) + : null; + $resultNarrowingAllowed = $methodReflection !== null + && !$methodReflection->hasSideEffects()->yes() + && ($this->rememberPossiblyImpureFunctionValues || $methodReflection->hasSideEffects()->no()); + + // the call's own type bridges through the stored plain result until + // MethodCallHandler migrates (PHPSTAN_FNSR=0) — then this composes for free + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $plainResult): Type { + if (!$e instanceof NullsafeMethodCall) { + throw new ShouldNotHappenException(); + } + + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + return new NullType(); + } + + $methodReturnType = $plainResult->getTypeForScope($s); + if (TypeCombinator::containsNull($varType)) { + return TypeCombinator::union($methodReturnType, new NullType()); + } + + return $methodReturnType; + }; + return new ExpressionResult( $scope, - hasYield: $exprResult->hasYield(), + hasYield: $plainResult->hasYield(), isAlwaysTerminating: false, - throwPoints: $exprResult->getThrowPoints(), - impurePoints: $exprResult->getImpurePoints(), + throwPoints: $plainResult->getThrowPoints(), + impurePoints: $plainResult->getImpurePoints(), truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult, $resultNarrowingAllowed), + companionResults: [$scope->getNodeKey($plainCall) => $plainResult], ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 1482170984..491fa85883 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -20,7 +20,6 @@ use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -30,7 +29,6 @@ use PHPStan\Php\PhpVersion; use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; -use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; @@ -134,8 +132,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - // rules keep seeing the virtual plain fetch, as the old delegation provided; - // their getType() asks resolve from the stored result below + // rules see the virtual plain fetch as the old delegation provided, and their + // asks about the subject must answer the narrowed type while the fetch part is + // in flight — the old world re-evaluated the subject on the narrowed scope + $narrowedVarResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr->var, + typeCallback: static function (Expr $e, MutatingScope $s) use ($varResult): Type { + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + // an always-null subject is not narrowed (the call is reported instead) + return $varType; + } + + return TypeCombinator::removeNull($varType); + }, + ); + $nodeScopeResolver->storeResult($storage, $expr->var, $narrowedVarResult); $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, $plainFetch, $scope, $storage, $context); $plainResult = new ExpressionResult( $scope, @@ -148,6 +165,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); $nodeScopeResolver->storeResult($storage, $plainFetch, $plainResult); + $nodeScopeResolver->storeResult($storage, $expr->var, $varResult); $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); @@ -180,58 +198,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: $this->createSpecifyTypesCallback($varResult), + specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult), companionResults: [$scope->getNodeKey($plainFetch) => $plainResult], ); } - /** - * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes - */ - private function createSpecifyTypesCallback(ExpressionResult $varResult): callable - { - return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult): SpecifiedTypes { - if (!$e instanceof NullsafePropertyFetch) { - throw new ShouldNotHappenException(); - } - - if ($ctx->null()) { - return (new SpecifiedTypes([], []))->setRootExpr($e); - } - - if (!$ctx->truthy()) { - $removedType = StaticTypeFactory::truthy(); - $chainExecuted = false; - } elseif (!$ctx->falsey()) { - $removedType = StaticTypeFactory::falsey(); - // a truthy result cannot have come from the short-circuit null - $chainExecuted = true; - } else { - return (new SpecifiedTypes([], []))->setRootExpr($e); - } - - $sureNotTypes = [ - $this->exprPrinter->printExpr($e) => [$e, $removedType], - ]; - - $varType = $varResult->getTypeForScope($s); - $varCanBeNull = TypeCombinator::containsNull($varType); - - if ($chainExecuted || !$varCanBeNull) { - // the plain-chain variant holds the same narrowing - $plain = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($e); - if ($plain !== $e) { - $sureNotTypes[$this->exprPrinter->printExpr($plain)] = [$plain, $removedType]; - } - } - - if ($chainExecuted && $varCanBeNull) { - // the chain executed, so the subject is not null - $sureNotTypes[$this->exprPrinter->printExpr($e->var)] = [$e->var, new NullType()]; - } - - return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); - }; - } - } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 8ca2da7005..a73b8758e7 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -303,6 +303,22 @@ public function nullsafeVariants(Holder $definite, ?Holder $maybe, string $prop) assertType('NewWorldTypeInference\\Holder|null', $maybe); } + public function nullsafeMethodCalls(Holder $definite, ?Holder $maybe): void + { + assertType('int', $definite?->getCount()); + assertType('int|null', $maybe?->getCount()); + + if ($maybe?->getCount()) { + assertType('NewWorldTypeInference\\Holder', $maybe); + assertType('int|int<1, max>', $maybe->getCount()); + } else { + assertType('NewWorldTypeInference\\Holder|null', $maybe); + } + + assertType('int|null', $maybe?->inner->getCount()); + assertNativeType('int|null', $maybe?->getCount()); + } + /** * @param array $holders */ @@ -535,6 +551,11 @@ class Holder public Holder $inner; + public function getCount(): int + { + return $this->count; + } + /** @var mixed */ public $untyped; From 39ff793550eaa974f48c62e1c959d8eca81cb397 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 19:56:01 +0200 Subject: [PATCH 19/50] Migrate BooleanAndHandler and BooleanOrHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-pass showcase: the right operand is evaluated on the left-truthy/left-falsey scope during processing, so the typeCallback composes the two child results directly — no processExprNode re-walk on a throwaway storage, no BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH, no flattened-chain fast path in the new world (the old ones stay for PHPSTAN_FNSR=0). A 6-arm boolean chain (depth 5 > the old cap 4) narrows every arm and constant-folds whole-chain asks under the migration meter. - specifyTypesCallback: the old narrowing math with child narrowing from the child ExpressionResults (specifyChildTypes); normalize() and the conditional-holder helpers price narrowing-original asks through ResultAwareScopes seeded per base scope — seeding a result under a different base answers original-type asks with already-narrowed evaluation-point types (is_bool($x) && $x falsey lost the non-bool half). - ExpressionResult::getTruthyScope()/getFalseyScope() consult the handler's scope callbacks first, the specify-callback reconstruction second. BooleanAnd's truthy scope is the right operand's truthy scope (incremental — the left narrowing is already part of it); re-deriving the whole conjunction re-unions per-arm types the old world never unioned and drifts representations (array vs array). Migrated handlers pass new-world scope callbacks or none — the legacy filterByTruthyValue($expr) bridges are stripped from Assign, FuncCall, PropertyFetch, both Nullsafe handlers and Variable. - FuncCallHandler::specifyTypesViaResults: the dynamic-name fall-through invokes the old-world body directly (specifyTypesFromCallableCall + default context) — re-dispatching through specifyTypesInCondition bounced an incoming adapter scope back into the seeded self-result forever. - Virtual nodes built by handlers embed toFiberScope() scopes in the new world (BooleanAndNode/BooleanOrNode right scope) so the ConstantCondition rules' getRightScope()->getType() asks resolve through the stored right result. - ResultAwareScope answers Yes-tracked plain variables from the holder — filter-derived adapters lose their context and variables fell through to the guarded bridge; superglobals (Yes-defined, no holder) keep falling through. Corpus: 17 new probes (deep chains, const folds via parent asks, inside-out narrowing, representation pins for both found regressions, statement null-context, Or-in-And falsey, unmigrated-arm fall-through, isset-holder re-derivation, dynamic-name call in condition). Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 65 ++++++- src/Analyser/ExprHandler/AssignHandler.php | 2 - .../ExprHandler/BooleanAndHandler.php | 150 +++++++++++++++- src/Analyser/ExprHandler/BooleanOrHandler.php | 137 +++++++++++++- src/Analyser/ExprHandler/FuncCallHandler.php | 14 +- .../ExprHandler/NullsafeMethodCallHandler.php | 2 - .../NullsafePropertyFetchHandler.php | 2 - .../ExprHandler/PropertyFetchHandler.php | 2 - src/Analyser/ExprHandler/VariableHandler.php | 2 - src/Analyser/ExpressionResult.php | 31 ++-- src/Analyser/ResultAwareScope.php | 15 ++ tests/PHPStan/Analyser/data/new-world.php | 168 ++++++++++++++++++ 12 files changed, 552 insertions(+), 38 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index d2f135e720..95b0bd73e5 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -139,6 +139,30 @@ deliverable at every point along the way. GC scans over live cyclic webs). Late asks build their adapters on a **fresh storage** instead — the synthetics-in-flight cycle guard threads through it, only known-result seeding is lost on those rare paths. +12. **`InitializerExprTypeResolver` keeps its `callable(Expr): Type` shape; the new world + feeds it a results-first callback** — the form `ArrayHandler` established: the closure + closes over the already-processed child `ExpressionResult`s (keyed by + `spl_object_id($expr)`), answers from `$childResult->getTypeForScope($s)` when the asked + expr is one of them, and only falls back to the guarded `$s->getType($inner)` bridge for + exprs it has no result for. Handlers migrating constructs that resolve through + `InitializerExprTypeResolver` (BinaryOp, ClassConstFetch, ConstFetch, UnaryMinus/Plus, + …) reuse this pattern instead of inventing per-handler plumbing. +13. **Branch scopes prefer the handler's scope callbacks; adapters are seeded per base + scope.** Two lessons from the BooleanAnd/Or leg, both found as mixed-mode nsrt diffs: + - `getTruthyScope()`/`getFalseyScope()` consult `truthyScopeCallback`/`falseyScopeCallback` + *first*, the specify-callback reconstruction second. A handler that can compose a + branch scope incrementally must say so: for `A && B` the truthy scope *is* the right + operand's truthy scope (the left narrowing is already part of it) — re-deriving the + whole conjunction from per-arm `SpecifiedTypes` re-unions types that were never + unioned in the old world and drifts representations (`array` vs + `array`). Consequence: migrated handlers pass *new-world* scope + callbacks or none at all — the old `filterByTruthyValue($expr)` bridges were stripped + when the preference flipped. + - A `ResultAwareScope` must be seeded only with results **evaluated on its base scope**. + A result's memoized type is its evaluation-point type, so seeding the right operand's + result (evaluated on the left-truthy scope) into the pre-condition adapter answered + narrowing-original asks with already-narrowed types (`is_bool($x) && $x` falsey lost + the non-bool half). Everything not seeded re-processes on the base scope (tier 4). ## 4. What we gain @@ -263,9 +287,9 @@ as factual comments at their call sites, not here. - [ ] AssignOpHandler - [ ] BinaryOpHandler — `typeCallback` done (Identical/NotIdentical bridge until the equality migration); `specifyTypesCallback` missing - [ ] BitwiseNotHandler -- [ ] BooleanAndHandler +- [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters - [ ] BooleanNotHandler -- [ ] BooleanOrHandler +- [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters - [ ] CastHandler - [ ] CastStringHandler - [ ] ClassConstFetchHandler @@ -485,6 +509,43 @@ as factual comments at their call sites, not here. Leg coverage: 89.5%+ of executable changed lines via 18 new corpus probes (non-nullable/null/array-dim subjects, chains, dynamic names, native asks, bare-statement context); the rest are defensive throws and rule-only paths. +- 2026-06-10 (boolean leg): **`BooleanAndHandler` + `BooleanOrHandler` migrated** — the + single-pass showcase. The right operand is evaluated on the left-truthy/left-falsey + scope during processing, so the typeCallback composes the two child results directly: + no `processExprNode` re-walk on a throwaway storage, no + `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened-chain fast path in the new world + (the old ones stay for FNSR=0). Meter demo: a 6-arm `&&`/`||` chain (depth 5 > old cap + 4) narrows every arm and constant-folds whole-chain asks under `disableOldWorld=true`. + specifyTypesCallback is the old math with child narrowing from the child results + (`specifyChildTypes`: result callback, or the old dispatcher *with the adapter scope* + for unmigrated children); `normalize()`/conditional-holder helpers price their + narrowing-original asks through `ResultAwareScope`s seeded per base scope (§3.13). + Found and fixed in the process: + - branch-scope preference flip + legacy-callback strip (§3.13 first bullet; the + representation drift showed up as 6 nsrt diffs: mixed-subtract, pr-5379, bug-9400, + bug-7156, while-loop-variables, bug-14047); + - per-base adapter seeding (§3.13 second bullet; `ReflectionProviderGoldenTest` + remembered-`is_bool()` leak); + - `FuncCallHandler::specifyTypesViaResults` dynamic-name fall-through invokes the + old-world body (`specifyTypesFromCallableCall` + default context) directly — the + dispatcher round-trip bounced an adapter scope back into the seeded self-result + forever (4.5M-frame stack in nsrt); + - virtual nodes built by handlers embed `toFiberScope()` scopes in the new world + (`BooleanAndNode`/`BooleanOrNode` right scope) so the ConstantCondition rules' + `getRightScope()->getType()` asks resolve through the stored right result; + - `ResultAwareScope` answers Yes-tracked plain variables from the holder (tier 2½) — + filter-derived adapters lose their context (`plainScope === null`) and variables + fell through to the guarded bridge; superglobals (Yes-defined, no holder) keep + falling through. + Scoreboard: corpus 194/194 (17 new probes: deep chains, const folds via parent asks, + inside-out narrowing, representation pins for both regressions, statement null-ctx, + Or-in-And falsey, unmigrated-arm fall-through, isset-holder re-derivation, dynamic-name + call in condition); nsrt at the known 6; CallMethods/BooleanAnd/BooleanOr/ImpossibleCheck + rule tests green; `make phpstan` 204 = HEAD parity; FNSR=0 spot checks byte-identical. + Changed-line coverage (corpus + guard-on meter merged): And 84%, Or 90%, + ResultAwareScope 100% — remaining gaps are defensive throws, closure-closing braces, + one fold return measured-missed under fibers (output proven by the meter), the + truthy-and-false holder re-derivation pair, and `setAlwaysOverwriteTypes` propagation. - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f88f2ff3e9..83ddb16bdb 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -408,8 +408,6 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: static function (Expr $e, MutatingScope $s) use (&$assignedExprResult): Type { if ($assignedExprResult === null) { diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 6a56274b37..1885790dd3 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -14,8 +14,10 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\ResultAwareScope; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -44,6 +46,7 @@ final class BooleanAndHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -252,14 +255,47 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftTruthyScope = $leftResult->getTruthyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getFalseyScope(); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); + // the embedded right scope answers the rules' getType()/getNativeType()/ + // narrowing asks about the right operand — in the new world those must go + // through the fiber so the stored right result answers them + $rightScopeForNode = NewWorld::isEnabled() ? $leftTruthyScope->toFiberScope() : $leftTruthyScope; + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $rightScopeForNode), $scope, $storage, $context); + + // the single-pass payoff: the right side was *evaluated* on the left-truthy + // scope, so its result already is what the old resolveType had to rebuild by + // re-processing the left side on a throwaway storage — no re-walk, no + // BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH + $typeCallback = static function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BooleanAnd && !$e instanceof LogicalAnd) { + throw new ShouldNotHappenException(); + } + + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + $rightBooleanType = $rightResult->getTypeForScope($s)->toBoolean(); + if ($rightBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $leftBooleanType->isTrue()->yes() + && $rightBooleanType->isTrue()->yes() + ) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; return new ExpressionResult( $leftMergedWithRightScope, @@ -267,9 +303,115 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), - falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), + // incremental truthy scope: the right operand was evaluated on the + // left-truthy scope, so its truthy scope IS the whole conjunction's — + // no re-derivation, no cross-arm combination (and no representational + // drift from re-uniting per-arm types). The falsey scope cannot be + // composed this way (¬(A && B) needs both arms) — specify path. + truthyScopeCallback: static fn (): MutatingScope => $rightResult->getTruthyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftTruthyScope), ); } + /** + * New-world copy of specifyTypes(): child narrowing comes from the child + * ExpressionResults — the recursion is structural, so deep chains compose + * linearly and the flattened fast path is not needed. The normalize/ + * conditional-holder helper code resolves narrowing originals with + * $scope->getType() — those asks are priced through adapters seeded with + * the operand results (fresh storage per ask, NEW_WORLD.md §3.11). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $leftResult, ExpressionResult $rightResult, MutatingScope $leftTruthyScope): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftTruthyScope): SpecifiedTypes { + if (!$e instanceof BooleanAnd && !$e instanceof LogicalAnd) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // each adapter is seeded only with the result evaluated on its base + // scope — a result's memoized type is its evaluation-point type, so + // seeding it under another base would answer asks about narrowing + // originals with already-narrowed types. Other asks re-process on the + // base scope (ResultAwareScope tier 4) + $adapterStorage = new ExpressionResultStorage(); + $scopeAdapter = $s->toResultAwareScope([$s->getNodeKey($e->left) => $leftResult], $nodeScopeResolver, $stmt, $adapterStorage); + $rightScopeAdapter = $leftTruthyScope->toResultAwareScope([$s->getNodeKey($e->right) => $rightResult], $nodeScopeResolver, $stmt, $adapterStorage); + + $leftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, $ctx)->setRootExpr($e); + $rightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, $ctx)->setRootExpr($e); + if ($ctx->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $leftNormalized = $leftTypes->normalize($scopeAdapter); + $rightNormalized = $rightTypes->normalize($rightScopeAdapter); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scopeAdapter, $rightScopeAdapter, $leftNormalized, $rightNormalized, $e->left, $e->right, false, $types); + } + if ($ctx->false()) { + $leftTypesForHolders = $leftTypes; + $rightTypesForHolders = $rightTypes; + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. + if ($ctx->truthy()) { + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $leftTypesForHolders = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, TypeSpecifierContext::createFalsey())->setRootExpr($e); + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $rightTypesForHolders = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, TypeSpecifierContext::createFalsey())->setRootExpr($e); + } + } + // For arms still empty (e.g. isset() on an array dim fetch), derive conditions + // from the truthy narrowing instead, swapping sure/sureNot types. + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $truthyLeftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $truthyRightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypesForHolders, $rightTypesForHolders, false, true, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypesForHolders, $leftTypesForHolders, false, true, $scopeAdapter, $e->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypesForHolders, $rightTypesForHolders, true, true, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypesForHolders, $leftTypesForHolders, true, true, $scopeAdapter, $e->left), + ]))->setRootExpr($e); + } + + return $types; + }; + } + + /** + * A child's narrowing from its ExpressionResult; not-yet-migrated children + * take the old-world dispatcher with the adapter scope, keeping their inner + * type lookups unguarded. + */ + private function specifyChildTypes(ExpressionResult $result, Expr $child, MutatingScope $scope, ResultAwareScope $adapterScope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $child, $context); + } + } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index d439a2c808..03de8d5573 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -12,8 +12,10 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\ResultAwareScope; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -43,6 +45,7 @@ final class BooleanOrHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -294,14 +297,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftFalseyScope = $leftResult->getFalseyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getTruthyScope(); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); + // the embedded right scope answers the rules' getType()/getNativeType()/ + // narrowing asks about the right operand — in the new world those must go + // through the fiber so the stored right result answers them + $rightScopeForNode = NewWorld::isEnabled() ? $leftFalseyScope->toFiberScope() : $leftFalseyScope; + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $rightScopeForNode), $scope, $storage, $context); + + // the single-pass payoff, mirrored from BooleanAndHandler: the right side + // was evaluated on the left-falsey scope — no re-walk, no depth cap + $typeCallback = static function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BooleanOr && !$e instanceof LogicalOr) { + throw new ShouldNotHappenException(); + } + + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + $rightBooleanType = $rightResult->getTypeForScope($s)->toBoolean(); + if ($rightBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ( + $leftBooleanType->isFalse()->yes() + && $rightBooleanType->isFalse()->yes() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }; return new ExpressionResult( $leftMergedWithRightScope, @@ -309,9 +343,104 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), + // incremental falsey scope: the right operand was evaluated on the + // left-falsey scope, so its falsey scope IS the whole disjunction's — + // no re-derivation, no cross-arm combination (and no representational + // drift from re-uniting per-arm types). The truthy scope cannot be + // composed this way (A || B truthy needs both arms) — specify path. + falseyScopeCallback: static fn (): MutatingScope => $rightResult->getFalseyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftFalseyScope), ); } + /** + * New-world copy of specifyTypes(): child narrowing comes from the child + * ExpressionResults — the recursion is structural, so deep chains compose + * linearly and the flattened fast path is not needed. The normalize/ + * conditional-holder helper code resolves narrowing originals with + * $scope->getType() — those asks are priced through adapters seeded with + * the operand results (fresh storage per ask, NEW_WORLD.md §3.11). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $leftResult, ExpressionResult $rightResult, MutatingScope $leftFalseyScope): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftFalseyScope): SpecifiedTypes { + if (!$e instanceof BooleanOr && !$e instanceof LogicalOr) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // each adapter is seeded only with the result evaluated on its base + // scope — a result's memoized type is its evaluation-point type, so + // seeding it under another base would answer asks about narrowing + // originals with already-narrowed types. Other asks re-process on the + // base scope (ResultAwareScope tier 4) + $adapterStorage = new ExpressionResultStorage(); + $scopeAdapter = $s->toResultAwareScope([$s->getNodeKey($e->left) => $leftResult], $nodeScopeResolver, $stmt, $adapterStorage); + $rightScopeAdapter = $leftFalseyScope->toResultAwareScope([$s->getNodeKey($e->right) => $rightResult], $nodeScopeResolver, $stmt, $adapterStorage); + + $leftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, $ctx)->setRootExpr($e); + $rightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftFalseyScope, $rightScopeAdapter, $ctx)->setRootExpr($e); + + if ($ctx->true()) { + if ( + $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes->normalize($rightScopeAdapter); + } elseif ( + $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() + || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes->normalize($scopeAdapter); + } else { + $leftNormalized = $leftTypes->normalize($scopeAdapter); + $rightNormalized = $rightTypes->normalize($rightScopeAdapter); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders($this->typeSpecifier, $scopeAdapter, $rightScopeAdapter, $e, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scopeAdapter, $rightScopeAdapter, $leftNormalized, $rightNormalized, $e->left, $e->right, true, $types); + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($ctx->true()) { + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypes, $rightTypes, false, false, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypes, $leftTypes, false, false, $scopeAdapter, $e->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypes, $rightTypes, true, false, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypes, $leftTypes, true, false, $scopeAdapter, $e->left), + ]))->setRootExpr($e); + } + + return $types; + }; + } + + /** + * A child's narrowing from its ExpressionResult; not-yet-migrated children + * take the old-world dispatcher with the adapter scope, keeping their inner + * type lookups unguarded. + */ + private function specifyChildTypes(ExpressionResult $result, Expr $child, MutatingScope $scope, ResultAwareScope $adapterScope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $child, $context); + } + } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 537fa15c91..630485d3fe 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -613,8 +613,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $nameResult, $scopeAfterArgs): SpecifiedTypes { @@ -828,8 +826,16 @@ private function specifyTypesViaResults( ): SpecifiedTypes { if (!$expr->name instanceof Name) { - // dynamic-name calls: guarded legacy bridge for now (PHPSTAN_FNSR=0) - return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); + // dynamic-name calls: the old-world body invoked directly for now + // (guarded except via an adapter) — re-dispatching through + // specifyTypesInCondition would bounce an incoming adapter scope + // straight back to this callback (seeded self-result) forever + $specifiedTypes = $this->specifyTypesFromCallableCall($this->typeSpecifier, $context, $expr, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + + return $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); } $adapterBase = $callSiteScope; diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 4e2bdb8b83..ba6edbe629 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -189,8 +189,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: $plainResult->getThrowPoints(), impurePoints: $plainResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult, $resultNarrowingAllowed), diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 491fa85883..4c9905891d 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -194,8 +194,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult), diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index d789994e03..07111d309f 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -91,8 +91,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $this->createTypeCallback($varResult), specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 2dd7e293d2..ad85c75a34 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -124,8 +124,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating, $throwPoints, $impurePoints, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 07c5583800..f66f53dd0e 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -200,6 +200,16 @@ public function getTruthyScope(): MutatingScope return $this->truthyScope; } + // a handler-provided scope callback is authoritative: handlers pass one + // when they can build the branch scope better than re-deriving the whole + // condition from scratch — e.g. BooleanAnd composes the right operand's + // truthy scope incrementally (the left narrowing is already part of it). + // Migrated handlers must pass new-world callbacks here or none at all. + if ($this->truthyScopeCallback !== null) { + $callback = $this->truthyScopeCallback; + return $this->truthyScope = $callback(); + } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { return $this->truthyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), @@ -207,13 +217,7 @@ public function getTruthyScope(): MutatingScope ); } - if ($this->truthyScopeCallback === null) { - return $this->scope; - } - - $callback = $this->truthyScopeCallback; - $this->truthyScope = $callback(); - return $this->truthyScope; + return $this->scope; } public function getFalseyScope(): MutatingScope @@ -222,6 +226,11 @@ public function getFalseyScope(): MutatingScope return $this->falseyScope; } + if ($this->falseyScopeCallback !== null) { + $callback = $this->falseyScopeCallback; + return $this->falseyScope = $callback(); + } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { return $this->falseyScope = $this->scope->applySpecifiedTypes( $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), @@ -229,13 +238,7 @@ public function getFalseyScope(): MutatingScope ); } - if ($this->falseyScopeCallback === null) { - return $this->scope; - } - - $callback = $this->falseyScopeCallback; - $this->falseyScope = $callback(); - return $this->falseyScope; + return $this->scope; } /** diff --git a/src/Analyser/ResultAwareScope.php b/src/Analyser/ResultAwareScope.php index 7021ea6ff6..7d3b4c269c 100644 --- a/src/Analyser/ResultAwareScope.php +++ b/src/Analyser/ResultAwareScope.php @@ -11,6 +11,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeUtils; use function array_key_exists; +use function is_string; /** * New-world adapter for code that receives a Scope and calls getType() on it @@ -218,6 +219,20 @@ private function resolveTypeViaResults(Expr $node): Type return $this->expressionTypes[$this->getNodeKey($node)]->getType(); } + if ( + $node instanceof Expr\Variable + && is_string($node->name) + && array_key_exists($this->getNodeKey($node), $this->expressionTypes) + && $this->hasExpressionType($node)->yes() + ) { + // a Yes-tracked variable's type is the holder itself — VariableHandler + // only adds undefined-variable handling, excluded by the Yes certainty. + // (Superglobals are Yes-defined without a holder — they fall through.) + // Keeps variable asks unguarded on filter-derived adapters that lost + // the adapter context (plainScope === null) + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + $key = $this->getNodeKey($node); if (array_key_exists($key, $this->exprResults)) { return $this->exprResults[$key]->getTypeForScope($this); diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index a73b8758e7..84b6069f59 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -532,6 +532,174 @@ public function negatedAndEqualityAsserts(): void assertType('5', $n); } + /** + * Single-pass composition through a chain deeper than the old + * BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4: each right operand is evaluated + * on the left-truthy scope, so the chain composes linearly with no re-walk. + */ + public function deepBooleanAndChain(bool $a, bool $b, bool $c, bool $d, bool $e, bool $f): void + { + if ($a && $b && $c && $d && $e && $f) { + assertType('true', $a); + assertType('true', $f); + assertType('true', $a && $b && $c && $d && $e && $f); + } + } + + public function deepBooleanOrChain(?int $a, ?int $b, ?int $c, ?int $d, ?int $e, ?int $f): void + { + if ($a || $b || $c || $d || $e || $f) { + assertType('bool', $a || $b || $c || $d || $e || $f); + } else { + assertType('0|null', $a); + assertType('0|null', $f); + } + } + + public function booleanConstantFolding(bool $b): void + { + assertType('true', 1 && 1); + assertType('bool', 1 && $b); + assertType('bool', 0 || $b); + assertType('true', 1 || $b); + assertType('false', $b && 0); + } + + /** the right operand sees the left-truthy/left-falsey scope */ + public function booleanInsideOutNarrowing(?bool $a, bool $b): void + { + if ($a && $b) { + assertType('true', $a); + assertType('true', $b); + } + if ($a || $b) { + assertType('bool|null', $a); + } else { + assertType('false|null', $a); + assertType('false', $b); + } + } + + /** + * The truthy scope of `A && B` is composed incrementally from the right + * operand's truthy scope — re-deriving the whole conjunction would union + * per-arm types and drift the representation (array vs the + * expected array from is_array()). + */ + public function booleanAndNarrowingRepresentation(mixed $m): void + { + if ($m != 0 && !is_array($m) && $m != null && !is_object($m)) { + assertType("mixed~(0|0.0|''|'0'|array|object|false|null)", $m); + } + } + + /** + * The falsey scope of `A && B` comes from the specify callback: narrowing + * originals must be the pre-condition types (per-base adapter seeding) — + * the remembered is_bool() narrowing of the truthy branch must not leak. + */ + public function booleanAndFalseyOriginals(Holder $h): void + { + if (is_bool($h->untyped) && $h->untyped) { + assertType('true', $h->untyped); + } else { + assertType('mixed~true', $h->untyped); + } + assertType('mixed', $h->untyped); + } + + /** + * A dynamic-name call as a boolean operand: its narrowing ask must not + * bounce between the adapter head-check and the FuncCall specify callback + * (the dynamic-name bridge invokes the old-world body directly). + * + * @param callable(): bool $f + */ + public function dynamicNameCallInCondition(callable $f, ?int $i): void + { + if ($i !== null && $f()) { + assertType('int', $i); + } + } + + public function booleanOrShortcutNarrowing(bool $b, bool $c): void + { + if (0 || $b) { + assertType('true', $b); + } + if ($b || 0) { + assertType('true', $b); + } + if (($b || $c) && 1) { + assertType('bool', $b); + } + } + + public function booleanStatementNullContext(bool $a, bool $b): void + { + $a && $b; + $a || $b; + assertType('bool', $a); + } + + public function booleanOrInsideAndFalsey(?int $a, ?int $b, bool $c): void + { + if (($a || $b) && $c) { + assertType('true', $c); + } else { + assertType('int|null', $a); + } + } + + public function booleanOrUnmigratedArm(?int $a, bool $b): void + { + if (!$a || $b) { + assertType('int|null', $a); + } else { + assertType('int|int<1, max>', $a); + } + } + + /** @param array $arr */ + public function booleanIssetHolderRederivation(array $arr): void + { + $ok = isset($arr['a']) && isset($arr['b']); + if ($ok) { + assertType('int', $arr['a']); + assertType('int', $arr['b']); + } + } + + public function booleanOverwriteArm(string $s, bool $b): void + { + if (in_array($s, ['a', 'b'], true) && $b) { + assertType("'a'|'b'", $s); + } + } + + /** constant folds asked through a parent boolean's specify callback */ + public function booleanFoldsViaParentAsks(bool $b, bool $c): void + { + if (($b && 0) || $c) { + assertType('true', $c); + } + if (($b || 1) || $c) { + assertType('bool', $c); + } + if ((0 || 0) || $c) { + assertType('true', $c); + } + } + + /** a negated exactly-true ask drives the mixed truthy-and-false context */ + public function booleanNegatedExactContext(mixed $m, bool $b): void + { + if (!($m instanceof Holder && $b) === false) { + assertType(Holder::class, $m); + assertType('bool', $b); + } + } + private function name(): string { return 'x'; From a9beb4f8b3e7afefaea95e8738fdb7b86f0e93a7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 20:22:15 +0200 Subject: [PATCH 20/50] Migrate TernaryHandler and BooleanNotHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ternary typeCallback composes the branch results — each branch was evaluated on the matching cond-narrowed scope during processing, so the old resolveType's re-processing of the condition on a throwaway storage dies (PHPSTAN_FNSR=0 keeps it). The short ternary asks the condition's type on its own truthy scope via getTypeOnScope, promoting the scope first for native asks. Narrowing rewrites the ternary into the same synthetic the old world used — (cond && if) || (!cond && else) — and processes it through the migrated boolean handlers (unseeded ResultAwareScope, tier 4). BooleanNot: constant folds via the inner result; incremental swapped branch scopes (the truthy scope of !X is X's falsey scope and vice versa); narrowing negates the context onto the inner result, with the old-world dispatcher and an unseeded adapter for not-yet-migrated inner expressions. AssignHandler's Ternary conditional-holder block is unlocked: the cond's narrowing comes from its re-processed result's specify callback and getTruthyScope()/getFalseyScope(), branch types are priced through adapters on the filtered scopes, and projected entry expressions resolve through a resolver mirroring the assign one. FNSR=0 keeps the old block. Found in the process: the nullsafe specify callback never ran the plain call's type-specifying extensions (@phpstan-assert-if-true, bug-12866) — the old synthetic BooleanAnd(var !== null, plainCall) dispatch provided that. Exposed by BooleanNot's incremental falsey scope being the nullsafe truthy scope. createNullsafeSpecifyCallback now composes the plain call's narrowing through the dispatcher when the chain executed, until MethodCallHandler's narrowing migrates. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 30 ++++- src/Analyser/ExprHandler/AssignHandler.php | 57 +++++++-- .../ExprHandler/BooleanNotHandler.php | 63 ++++++++- .../Helper/DefaultNarrowingHelper.php | 30 ++++- .../ExprHandler/NullsafeMethodCallHandler.php | 2 +- src/Analyser/ExprHandler/TernaryHandler.php | 112 +++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 121 ++++++++++++++++++ 7 files changed, 388 insertions(+), 27 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 95b0bd73e5..cdcdd4d67d 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -288,7 +288,7 @@ as factual comments at their call sites, not here. - [ ] BinaryOpHandler — `typeCallback` done (Identical/NotIdentical bridge until the equality migration); `specifyTypesCallback` missing - [ ] BitwiseNotHandler - [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters -- [ ] BooleanNotHandler +- [x] BooleanNotHandler — typeCallback folds via the inner result; incremental branch scopes (truthy(!X) = X's falsey scope, §3.13); specifyTypesCallback negates the context onto the inner result (unseeded adapter for unmigrated inner) - [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters - [ ] CastHandler - [ ] CastStringHandler @@ -325,7 +325,7 @@ as factual comments at their call sites, not here. - [x] ScalarHandler - [ ] StaticCallHandler - [ ] StaticPropertyFetchHandler -- [ ] TernaryHandler +- [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) - [ ] ThrowHandler - [ ] UnaryMinusHandler - [ ] UnaryPlusHandler @@ -546,6 +546,32 @@ as factual comments at their call sites, not here. ResultAwareScope 100% — remaining gaps are defensive throws, closure-closing braces, one fold return measured-missed under fibers (output proven by the meter), the truthy-and-false holder re-derivation pair, and `setAlwaysOverwriteTypes` propagation. +- 2026-06-10 (ternary leg): **`TernaryHandler` + `BooleanNotHandler` migrated**, and the + AssignHandler Ternary conditional-holder block unlocked. The ternary typeCallback + composes the branch results — each was evaluated on the matching cond-narrowed scope, + so the old resolveType's cond re-processing on a throwaway storage dies; the short + ternary asks the cond on its truthy scope via `getTypeOnScope` (native asks promote the + truthy scope first). Narrowing rewrites into the old `(cond && if) || (!cond && else)` + synthetic and processes it through the *migrated* boolean handlers (unseeded adapter, + tier 4 — §3.13). BooleanNot: fold via the inner result, incremental swapped branch + scopes (truthy(!X) = X's falsey scope), context negation onto the inner result. + AssignHandler's Ternary holder block now takes the cond result's specify callback + + getTruthyScope/getFalseyScope, adapter-priced branch types, and an entry resolver + mirroring the assign one (FNSR=0 keeps the old block verbatim). + Found and fixed: the nullsafe specify callback never ran the plain call's + type-specifying extensions (`@phpstan-assert-if-true`, bug-12866) — the old synthetic + `BooleanAnd(var !== null, plainCall)` dispatch provided that; exposed by BooleanNot's + incremental falsey = nullsafe truthy. `createNullsafeSpecifyCallback` now composes the + plain call's narrowing through the dispatcher (adapter) when the chain executed — + until MethodCallHandler narrowing migrates. + Scoreboard: corpus 219/219 (25 new probes: ternary basics/folds/short-as-condition/ + native, ternary-as-condition synthetic, assign holders incl. untracked entries, + statement null-ctx ×2, BooleanNot folds and branch narrowing, bug-12866 pin); meter + demo (8 dumps) green under disableOldWorld=true and byte-identical under FNSR=0; + nsrt at the known 6; make phpstan 204 = parity; CallMethods/Ternary/BooleanNot/ + BooleanAnd/BooleanOr rule tests green. Changed-line coverage: Ternary 88%, + BooleanNot 85%, Assign 86%, DefaultNarrowingHelper 100% — gaps are defensive throws, + braces, the FNSR=0-only branch and one tier-3 delegation line. - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 83ddb16bdb..8c0c69761c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -517,30 +517,61 @@ public function processAssignVar( : $scopeBeforeAssignEval->getType($assignedExpr); // Ternary/Match conditional-expression holders need the branch types from - // narrowed scopes — guarded old-world bridges until TernaryHandler/ - // MatchHandler migrate (PHPSTAN_FNSR=0) + // narrowed scopes — the cond's narrowing comes from its re-processed + // result, the branch types are priced through adapters on the filtered + // scopes (Match: guarded old-world bridge until MatchHandler migrates) $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { $if = $assignedExpr->cond; } - $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $truthyScope->getType($if); - $falseyType = $falsyScope->getType($assignedExpr->else); + $condResult = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep()); + $condScope = $condResult->getScope(); + if (NewWorld::isEnabled() && $condResult->hasSpecifiedTypesCallback()) { + $truthySpecifiedTypes = $condResult->getSpecifiedTypes($condScope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $condResult->getSpecifiedTypes($condScope, TypeSpecifierContext::createFalsey()); + $truthyScope = $condResult->getTruthyScope(); + $falsyScope = $condResult->getFalseyScope(); + } else { + // not-yet-migrated cond — guarded old-world dispatcher (PHPSTAN_FNSR=0) + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); + } + if (NewWorld::isEnabled()) { + $truthyType = $truthyScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($if); + $falseyType = $falsyScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($assignedExpr->else); + } else { + $truthyType = $truthyScope->getType($if); + $falseyType = $falsyScope->getType($assignedExpr->else); + } if ( $truthyType->isSuperTypeOf($falseyType)->no() && $falseyType->isSuperTypeOf($truthyType)->no() ) { - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $condExprTypeResolver = null; + if (NewWorld::isEnabled()) { + // resolves entry expressions of the projected cond + // SpecifiedTypes — same tiers as $exprTypeResolver below + $condExprString = $condScope->getNodeKey($assignedExpr->cond); + $condExprTypeResolver = static function (Expr $e, string $eString) use ($condExprString, $condResult, $condScope, $nodeScopeResolver, $stmt, $storage): Type { + if ($eString === $condExprString && $condResult->hasTypeCallback()) { + return $condResult->getType(); + } + if (array_key_exists($eString, $condScope->expressionTypes)) { + return TypeUtils::resolveLateResolvableTypes($condScope->expressionTypes[$eString]->getType()); + } + + return $condScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($e); + }; + } + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $condExprTypeResolver); } } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 6bd2831fb2..f60f0d5ac1 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; @@ -27,6 +29,13 @@ final class BooleanNotHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + private TypeSpecifier $typeSpecifier, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof BooleanNot; @@ -37,17 +46,67 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); + $typeCallback = static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof BooleanNot) { + throw new ShouldNotHappenException(); + } + + $exprBooleanType = $exprResult->getTypeForScope($s)->toBoolean(); + if ($exprBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($exprBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + return new ExpressionResult( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + // incremental branch scopes (§3.13): `!X` is truthy exactly when X is + // falsey — the inner result's branch scopes, swapped + truthyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + falseyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult), ); } + /** + * New-world copy of specifyTypes(): the inner expression's narrowing with + * the context negated; a not-yet-migrated inner takes the old-world + * dispatcher with an unseeded adapter (the inner must be evaluated on the + * ask scope, §3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult): SpecifiedTypes { + if (!$e instanceof BooleanNot) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + if ($exprResult->hasSpecifiedTypesCallback()) { + return $exprResult->getSpecifiedTypes($s, $ctx->negate())->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $e->expr, $ctx->negate())->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $exprBooleanType = $scope->getType($expr->expr)->toBoolean(); diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php index b214643800..aae39806ba 100644 --- a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -3,10 +3,14 @@ namespace PHPStan\Analyser\ExprHandler\Helper; use PhpParser\Node\Expr; +use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -29,7 +33,10 @@ final class DefaultNarrowingHelper { - public function __construct(private ExprPrinter $exprPrinter) + public function __construct( + private ExprPrinter $exprPrinter, + private TypeSpecifier $typeSpecifier, + ) { } @@ -59,12 +66,18 @@ public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): * plain-chain dual key (one structural getNullsafeShortcircuitedExpr call) * and, when the chain provably executed, a subject-not-null entry. * + * When the chain executed and a plain call expr is supplied, the plain + * call's own narrowing (method type-specifying extensions, asserts) is + * composed in through the old-world dispatcher with an adapter — what the + * old synthetic `BooleanAnd(var !== null, plainCall)` provided — until + * MethodCallHandler's narrowing migrates. + * * @param Expr\NullsafePropertyFetch|Expr\NullsafeMethodCall $expr * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes */ - public function createNullsafeSpecifyCallback(Expr $expr, ExpressionResult $varResult, bool $resultNarrowingAllowed = true): callable + public function createNullsafeSpecifyCallback(Expr $expr, ExpressionResult $varResult, bool $resultNarrowingAllowed = true, ?Expr $plainCallExpr = null, ?NodeScopeResolver $nodeScopeResolver = null, ?Stmt $stmt = null): callable { - return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult, $resultNarrowingAllowed): SpecifiedTypes { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult, $resultNarrowingAllowed, $plainCallExpr, $nodeScopeResolver, $stmt): SpecifiedTypes { if (!$e instanceof Expr\NullsafePropertyFetch && !$e instanceof Expr\NullsafeMethodCall) { throw new ShouldNotHappenException(); } @@ -108,7 +121,16 @@ public function createNullsafeSpecifyCallback(Expr $expr, ExpressionResult $varR $sureNotTypes[$this->exprPrinter->printExpr($e->var)] = [$e->var, new NullType()]; } - return (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); + $types = (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); + + if ($chainExecuted && $plainCallExpr !== null && $nodeScopeResolver !== null && $stmt !== null) { + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $types = $types->unionWith( + $this->typeSpecifier->specifyTypesInCondition($adapterScope, $plainCallExpr, $ctx)->setRootExpr($e), + ); + } + + return $types; }; } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index ba6edbe629..aeffd5fc3c 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -191,7 +191,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $plainResult->getImpurePoints(), expr: $expr, typeCallback: $typeCallback, - specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult, $resultNarrowingAllowed), + specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult, $resultNarrowingAllowed, $plainCall, $nodeScopeResolver, $stmt), companionResults: [$scope->getNodeKey($plainCall) => $plainResult], ); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 3dcc769ad3..349678cc1e 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; @@ -19,6 +20,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -33,6 +35,8 @@ final class TernaryHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -106,6 +110,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifTrueScope = $ternaryCondResult->getTruthyScope(); $ifFalseScope = $ternaryCondResult->getFalseyScope(); $ifTrueType = null; + $ifResult = null; if ($expr->if === null) { $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); @@ -117,7 +122,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $ifResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifTrueScope->getType($expr->if); + $ifTrueType = $ifResult->getType(); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -125,7 +130,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } - $condType = $scope->getType($expr->cond); + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; } elseif ($condType->isFalse()->yes()) { @@ -134,7 +139,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $ifFalseScope->getType($expr->else); + $ifFalseType = $elseResult->getType(); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; @@ -144,15 +149,112 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + // the single-pass payoff: each branch was evaluated on the matching + // cond-narrowed scope, so the result type composes from the branch + // results — the old resolveType re-processed the condition on a + // throwaway storage to rebuild those scopes + $typeCallback = static function (Expr $e, MutatingScope $s) use ($ternaryCondResult, $ifResult, $elseResult): Type { + if (!$e instanceof Ternary) { + throw new ShouldNotHappenException(); + } + + $booleanCondType = $ternaryCondResult->getTypeForScope($s)->toBoolean(); + + if ($e->if === null) { + // short ternary: the truthy value is the condition itself, + // narrowed by its own truthiness + $truthyScope = $ternaryCondResult->getTruthyScope(); + if ($s->nativeTypesPromoted) { + $promotedTruthyScope = $truthyScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedTruthyScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $truthyScope = $promotedTruthyScope; + } + + if ($booleanCondType->isTrue()->yes()) { + return $ternaryCondResult->getTypeOnScope($truthyScope); + } + + if ($booleanCondType->isFalse()->yes()) { + return $elseResult->getTypeForScope($s); + } + + return TypeCombinator::union( + TypeCombinator::removeFalsey($ternaryCondResult->getTypeOnScope($truthyScope)), + $elseResult->getTypeForScope($s), + ); + } + + if ($ifResult === null) { + throw new ShouldNotHappenException(); + } + + if ($booleanCondType->isTrue()->yes()) { + return $ifResult->getTypeForScope($s); + } + + if ($booleanCondType->isFalse()->yes()) { + return $elseResult->getTypeForScope($s); + } + + return TypeCombinator::union( + $ifResult->getTypeForScope($s), + $elseResult->getTypeForScope($s), + ); + }; + return new ExpressionResult( $finalScope, hasYield: $ternaryCondResult->hasYield(), isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $finalScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $finalScope->filterByFalseyValue($expr), + // branch scopes via the specify path (§3.13) — a ternary's narrowing + // cannot be composed incrementally from one child + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt), ); } + /** + * New-world copy of specifyTypes(): the ternary rewrites itself into the + * same synthetic disjunction the old world used — + * `(cond && if) || (!cond && else)` — and the synthetic is processed on + * demand through the migrated BooleanOr/BooleanAnd handlers + * (ResultAwareScope tier 4). No seeds: the synthetic's children must be + * evaluated on the ask scope (§3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Ternary) { + throw new ShouldNotHappenException(); + } + + if ($e->cond instanceof Ternary || $ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + if ($e->if !== null) { + $conditionExpr = new BooleanOr( + new BooleanAnd($e->cond, $e->if), + new BooleanAnd(new Expr\BooleanNot($e->cond), $e->else), + ); + } else { + $conditionExpr = new BooleanOr( + $e->cond, + new BooleanAnd(new Expr\BooleanNot($e->cond), $e->else), + ); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $conditionExpr, $ctx)->setRootExpr($e); + }; + } + } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 84b6069f59..c3dffe04f8 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -700,6 +700,107 @@ public function booleanNegatedExactContext(mixed $m, bool $b): void } } + /** + * Each ternary branch was evaluated on the matching cond-narrowed scope — + * the result type composes from the branch results (no cond re-processing). + */ + public function ternaryBasics(bool $b, ?int $a): void + { + assertType('1|2', $b ? 1 : 2); + assertType("'x'|int|int<1, max>", $a ?: 'x'); + assertType("'a'", 1 ? 'a' : 'b'); + assertType("'b'", 0 ? 'a' : 'b'); + assertType('int|int<1, max>', $a ? $a : 5); + } + + /** ternary narrowing: the synthetic (cond && if) || (!cond && else) */ + public function ternaryAsCondition(?bool $b, ?int $c): void + { + if ($b ? $c : 0) { + assertType('true', $b); + assertType('int|int<1, max>', $c); + } + } + + public function ternaryStatementNullContext(bool $b): void + { + $b ? 1 : 2; + assertType('bool', $b); + } + + /** + * Conditional-expression holders projected from a ternary assignment: + * pinning one boolean value pins the recorded cond narrowing. + */ + public function ternaryAssignConditionalHolders(mixed $m): void + { + $flag = $m instanceof Holder ? 1 : 0; + if ($flag === 1) { + assertType(Holder::class, $m); + } else { + assertType('mixed~'.Holder::class, $m); + } + } + + public function booleanNotFolds(bool $b, ?int $a): void + { + assertType('false', !1); + assertType('true', !0); + assertType('bool', !$b); + if (!$a) { + assertType('0|null', $a); + } else { + assertType('int|int<1, max>', $a); + } + assertType('bool', !!$b); + } + + /** + * `!$i?->isA()` falsey = the nullsafe truthy scope: the plain call's + * type-specifying extensions (assert-if-true) compose into the nullsafe + * narrowing (bug-12866 regression). + */ + public function nullsafeAssertIfTrueNarrowing(?AssertingInterface $i): void + { + if (!$i?->isA()) { + return; + } + + assertType(AssertedClass::class, $i); + } + + public function ternaryShortFoldsAndNative(?int $a): void + { + assertType('1', 1 ?: 'x'); + assertType("'x'", 0 ?: 'x'); + assertNativeType("'x'|int|int<1, max>", $a ?: 'x'); + } + + public function ternaryShortAsCondition(?int $a, ?int $b): void + { + if ($a ?: $b) { + assertType('int|null', $a); + } else { + assertType('0|null', $a); + assertType('int|null', $b); + } + } + + public function booleanNotStatementNullContext(bool $b): void + { + !$b; + assertType('bool', $b); + } + + /** untracked compound entries in projected ternary-assign holders */ + public function ternaryAssignUntrackedEntries(Holder $h): void + { + $flag = is_int($h->untyped) ? 1 : 0; + if ($flag === 1) { + assertType('int', $h->untyped); + } + } + private function name(): string { return 'x'; @@ -729,6 +830,26 @@ public function getCount(): int } +interface AssertingInterface +{ + + /** + * @phpstan-assert-if-true AssertedClass $this + */ + public function isA(): bool; + +} + +class AssertedClass implements AssertingInterface +{ + + public function isA(): bool + { + return true; + } + +} + function mixedValue(): mixed { return 1; From 11ceac87272fdceab5955a8ed64a54040bd406a8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:22:34 +0200 Subject: [PATCH 21/50] Replace statement-level Scope::getType() asks in NodeScopeResolver with ExpressionResult asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD'd under disableOldWorld=true with a statement exerciser covering if/elseif/else, while (incl. always-true), do-while, for, foreach, switch, const, unset and @var annotations — all green under the guard and byte-identical under PHPSTAN_FNSR=0. - If/elseif: the next-arm scope is the elseif cond result's falsey scope. - While: before-cond and last-pass cond booleans come from the cond results; the loop-exit scope goes through the new filterByFalseyValueUsingResult() (apply path for migrated conds, guarded filterByFalseyValue otherwise). - Do-while: the condition is processed once, hoisted above the always-iterates check and the DoWhileLoopConditionNode callback; the single result feeds the boolean, the falsey scope and the points. - For: the last-cond result is captured (was discarded) and feeds always-iterates and post-loop falsey filtering; the count-pattern asks in inferForLoopExpressions are priced through adapters. - Foreach: getForeachIterateeTypes() computes the iteratee PHPDoc and native type pair per originalScope and threads it through the enterForeach helper, the constant-array unroll, and the new MutatingScope::enterForeach/enterForeachKey signatures (the methods no longer ask for types; no external callers existed). Post-loop dim-fetch/key/value re-asks go through per-scope adapters; the traversable throw point takes the iteratee type. - Switch: exhaustiveness asks the cond result on the case-narrowed scope via getTypeOnScope. - Const_/ClassConst take the value result's types; the Unset_ dim-var, findEarlyTerminatingExpr called-on types, processStmtVarAnnotation and execution-end never-checks, and AssignHandler's by-ref array keys go through adapters. - MethodThrowPointHelper takes a lazy return-type callback (FuncCallHandler's shape); MethodCall/StaticCall pass none yet, keeping the guarded bridge until their migrations. - MutatingScope::specifyExpressionType's ArrayDimFetch parent-update reads dim/var/native types holder-first (getTypeFromTrackedHolder) — Yes-tracked expressions answer from their holders without the guarded resolveType walk. - LiteralArrayItem embeds fiber scopes in the new world so rules' asks about item keys resolve through stored results — the virtual-node scope-embedding rule generalized from BooleanAndNode/BooleanOrNode. - ThrowHandler migrated en passant (constant never type, inner result's type for the throw point). Remaining raw asks are documented residue: rule callbacks already receive FiberScope, FNSR=0 branches, the recursive by-ref closure-use self-ask (ClosureHandler leg), the by-ref args fallback, the createCallableParameters type callbacks (priced by callers), and filterBy* over engine synthetics (the equality leg). Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 49 +++++- src/Analyser/ExprHandler/ArrayHandler.php | 5 +- src/Analyser/ExprHandler/AssignHandler.php | 10 +- .../Helper/MethodThrowPointHelper.php | 13 +- src/Analyser/ExprHandler/ThrowHandler.php | 10 +- src/Analyser/MutatingScope.php | 54 ++++-- src/Analyser/NodeScopeResolver.php | 157 ++++++++++++------ tests/PHPStan/Analyser/data/new-world.php | 73 ++++++++ 8 files changed, 302 insertions(+), 69 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index cdcdd4d67d..758f0b0ff8 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -326,7 +326,7 @@ as factual comments at their call sites, not here. - [ ] StaticCallHandler - [ ] StaticPropertyFetchHandler - [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) -- [ ] ThrowHandler +- [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback - [ ] UnaryMinusHandler - [ ] UnaryPlusHandler - [x] VariableHandler — dynamic variable names bridge @@ -572,6 +572,53 @@ as factual comments at their call sites, not here. BooleanAnd/BooleanOr rule tests green. Changed-line coverage: Ternary 88%, BooleanNot 85%, Assign 86%, DefaultNarrowingHelper 100% — gaps are defensive throws, braces, the FNSR=0-only branch and one tier-3 delegation line. +- 2026-06-10 (NodeScopeResolver getType sweep): **statement-level `Scope::getType()` + asks replaced with ExpressionResult asks**, TDD'd under `disableOldWorld=true` with a + 13-construct statement exerciser (if/elseif/else, while incl. always-true, do-while, + for, foreach, switch, const, unset, @var annotations). Per construct: + - If/elseif: next-arm scope = the elseif cond result's falsey scope. + - While: before-cond boolean and last-pass cond boolean from the cond results; the + loop-exit scope via the new `filterByFalseyValueUsingResult()` (apply path when the + cond result carries a specify callback, guarded `filterByFalseyValue` for unmigrated + conds / FNSR=0). + - Do-while: the cond is processed once (hoisted above the always-iterates check and + the DoWhileLoopConditionNode callback — rules now see cond sub-exprs first) and the + single result feeds the boolean, falsey scope, and throw/impure points. + - For: the last-cond result is captured (was discarded) and feeds always-iterates + + post-loop falsey filtering; `inferForLoopExpressions` count-pattern asks priced + through adapters. + - Foreach: `getForeachIterateeTypes()` computes the iteratee PHPDoc+native pair per + `$originalScope` (memoized result asks on the eval scope, `getTypeOnScope` on the + pollute-filtered one) and threads it through the NSR `enterForeach` helper, the + constant-array unroll, and new `MutatingScope::enterForeach`/`enterForeachKey` + signatures (no internal asks; no external callers existed); the post-loop dim-fetch/ + key/value re-asks go through per-scope adapters; the traversable throw point takes + the iteratee type. + - Switch: exhaustiveness asks the cond result on the case-narrowed scope + (`getTypeOnScope`); per-case `Equal` synthetics stay with the equality leg. + - Const_/ClassConst: the value result's types; Unset_ dim-var via adapter; + `findEarlyTerminatingExpr` called-on types via adapter; `processStmtVarAnnotation` + and execution-end never-checks via adapters; AssignHandler by-ref array keys via + adapter; `MethodThrowPointHelper` takes a lazy return-type callback (FuncCall's + shape; MethodCall/StaticCall pass null = guarded bridge until their legs). + - `MutatingScope::specifyExpressionType`'s ArrayDimFetch parent-update reads the dim/ + var/native types holder-first (`getTypeFromTrackedHolder`) — the plan's §C tier-1 + resolution, hit via `enterForeachKey`'s dim-fetch holder assignment. + - `LiteralArrayItem` embeds fiber scopes in the new world (DuplicateKeys rule asks) — + the BooleanAndNode/BooleanOrNode lesson generalized: any handler-built virtual node + carrying a scope embeds `toFiberScope()`. + - ThrowHandler migrated en passant (constant never type). + Documented residue (raw asks that stay): rule-callback closures already receive + FiberScope (NSR:958); FNSR=0 branches (If cond ternaries); recursive by-ref + closure-use self-ask (ClosureHandler leg); by-ref args fallback (documented); + `createCallableParameters` type callbacks (task #18 — callers price them); + `filterBy*` over engine synthetics (`$arrayComparisonExpr`, switch case `Equal`, + maybe-empty foreach merge — the equality/BinaryOp leg). + Scoreboard: corpus 232/232 (+13 statement probes); meter exerciser green under the + guard and byte-identical under FNSR=0; nsrt at the known 6; `make phpstan` 204 = + parity (3 fresh findings fixed: redundant null check, unused truthy helper variant, + Scope-vs-MutatingScope on the end-node adapter); DefinedVariableRuleTest + testDynamicAccess failure confirmed pre-existing at HEAD. - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index e879352636..0200d02a46 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -84,7 +85,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; foreach ($expr->items as $arrayItem) { - $itemNodes[] = new LiteralArrayItem($scope, $arrayItem); + // the embedded item scope answers the rules' getType() asks — in the + // new world those must go through the fiber so stored results answer + $itemNodes[] = new LiteralArrayItem(NewWorld::isEnabled() ? $scope->toFiberScope() : $scope, $arrayItem); $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage); if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep()); diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 8c0c69761c..8de76347b5 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -682,7 +682,7 @@ public function processAssignVar( } if ($assignedExpr instanceof Expr\Array_) { - $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $stmt, $storage, $scope, $var->name, $assignedExpr, new Variable($var->name)); } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); @@ -1521,12 +1521,14 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr return $scope->hasVariableType($varNode->name)->negate(); } - private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope + private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage, MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope { $implicitIndex = 0; foreach ($arrayExpr->items as $arrayItem) { if ($arrayItem->key !== null) { - $keyType = $scope->getType($arrayItem->key)->toArrayKey(); + // literal keys were just processed as part of the RHS — priced + // through the adapter (ResultAwareScope tier 4) + $keyType = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($arrayItem->key)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1552,7 +1554,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam if ($arrayItem->value instanceof Expr\Array_) { $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $stmt, $storage, $scope, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 08ff873558..a1488d78ee 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -14,6 +14,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -31,14 +32,22 @@ public function __construct( { } + /** + * @param (callable(): Type)|null $returnTypeCallback lazily resolves the + * call's return type for the explicit-never and implicit-throws + * checks — mirrors FuncCallHandler's shape; null keeps the guarded + * legacy scope ask (PHPSTAN_FNSR=0) until the call handlers migrate + */ public function getThrowPoint( MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, MethodCall|StaticCall $normalizedMethodCall, MutatingScope $scope, ExpressionContext $context, + ?callable $returnTypeCallback = null, ): ?InternalThrowPoint { + $returnTypeCallback ??= static fn (): Type => $scope->getType($normalizedMethodCall); if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicMethodThrowTypeExtensions() as $extension) { if (!$extension->isMethodSupported($methodReflection)) { @@ -77,7 +86,7 @@ public function getThrowPoint( $throwType = $methodReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedMethodCall); + $returnType = $returnTypeCallback(); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -88,7 +97,7 @@ public function getThrowPoint( return InternalThrowPoint::createExplicit($scope, $throwType, $normalizedMethodCall, true); } } elseif ($this->implicitThrows) { - $methodReturnedType = $scope->getType($normalizedMethodCall); + $methodReturnedType = $returnTypeCallback(); if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedMethodCall); } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 63c9b4720e..f25fcf83a4 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -28,6 +29,10 @@ final class ThrowHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Throw_; @@ -41,8 +46,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope, hasYield: false, isAlwaysTerminating: true, - throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), + throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getType(), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: static fn (): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 6cdf7d3b45..c68d3ccc60 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2463,10 +2463,8 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName, bool $valueByRef): self + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); $scope = $this->assignVariable( @@ -2492,7 +2490,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN ); } if ($keyName !== null) { - $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( @@ -2506,11 +2504,8 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN return $scope; } - public function enterForeachKey(self $originalScope, Expr $iteratee, string $keyName): self + public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); - $keyType = $originalScope->getIterableKeyType($iterateeType); $nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType); @@ -2857,6 +2852,43 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + /** + * Holder-first type read for internal engine bookkeeping (the ArrayDimFetch + * parent-update below): a Yes-tracked expression answers from its holder + * without the guarded resolveType walk — the dim and var of a tracked dim + * fetch are typically holders in the very scope being updated. Anything + * else takes the guarded bridge (PHPSTAN_FNSR=0). + */ + private function getTypeFromTrackedHolder(Expr $expr): Type + { + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->expressionTypes) + && $this->hasExpressionType($expr)->yes() + ) { + return TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()); + } + } + + return $this->getType($expr); + } + + private function getNativeTypeFromTrackedHolder(Expr $expr): Type + { + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->nativeExpressionTypes) + && $this->hasExpressionType($expr)->yes() + ) { + return TypeUtils::resolveLateResolvableTypes($this->nativeExpressionTypes[$exprString]->getType()); + } + } + + return $this->getNativeType($expr); + } + public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { if ($expr instanceof Scalar) { @@ -2890,9 +2922,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getType($expr->dim)->toArrayKey(); + $dimType = $scope->getTypeFromTrackedHolder($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getTypeFromTrackedHolder($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -2916,7 +2948,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, $scope = $scope->specifyExpressionType( $expr->var, $varType, - $scope->getNativeType($expr->var), + $scope->getNativeTypeFromTrackedHolder($expr->var), $certainty, ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f93753122a..846945c014 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1111,7 +1111,7 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope, $result->getType()); + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope, $stmt, $storage, $result->getType()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -1368,7 +1368,7 @@ public function processStmtNode( $lastElseIfConditionIsTrue = true; } - $condScope = $condScope->filterByFalseyValue($elseif->cond); + $condScope = $condResult->getFalseyScope(); $scope = $condScope; } @@ -1431,6 +1431,7 @@ public function processStmtNode( ); $this->callNodeCallback($nodeCallback, new InForeachNode($stmt), $scope, $storage); $originalScope = $scope; + [$iterateeType, $nativeIterateeType] = $this->getForeachIterateeTypes($condResult, $originalScope); $bodyScope = $scope; if ($stmt->keyVar instanceof Variable) { @@ -1452,19 +1453,20 @@ public function processStmtNode( $storage = $originalStorage->duplicate(); $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + [$iterateeType, $nativeIterateeType] = $this->getForeachIterateeTypes($condResult, $originalScope); + $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $iterateeType, $nativeIterateeType, $originalScope, $originalStorage, $context); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; $unrolledEndScope = $unrolledResult['endScope']; $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $count = 0; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -1484,7 +1486,7 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage; - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); @@ -1519,7 +1521,7 @@ public function processStmtNode( $finalScope = $unrolledEndScope; } - $exprType = $scope->getType($stmt->expr); + $exprType = $condResult->getType(); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1538,6 +1540,10 @@ public function processStmtNode( $arrayDimFetchLoopTypes = []; $keyLoopTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { + // the dim fetch and both loop variables are holder-tracked in these + // scopes (enterForeachKey assigns the dim fetch) — the adapter answers + // them without re-walking (NEW_WORLD.md §3.13) + $scopeWithIterableValueType = $scopeWithIterableValueType->toResultAwareScope([], $this, $stmt, $storage); $dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the @@ -1561,6 +1567,7 @@ public function processStmtNode( $arrayDimFetchLoopNativeTypes = []; $keyLoopNativeTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { + $scopeWithIterableValueType = $scopeWithIterableValueType->toResultAwareScope([], $this, $stmt, $storage); $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { $valueVarNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); @@ -1587,7 +1594,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $scope->getNativeType($stmt->expr); + $nativeExprType = $condResult->getNativeType(); $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -1635,7 +1642,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); } - $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr); + $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr, $condResult->getType()); if ($traversableThrowPoint !== null) { $throwPoints[] = $traversableThrowPoint; } @@ -1655,7 +1662,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1699,14 +1706,15 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $lastCondResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $lastCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + $finalScope = $this->filterByFalseyValueUsingResult($finalScopeResult->getScope(), $lastCondResult, $stmt->cond); $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $lastCondResult->getType() : $lastCondResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1802,9 +1810,11 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1820,13 +1830,10 @@ public function processStmtNode( $finalScope = $scope; } if (!$alwaysTerminating) { - $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); $finalScope = $condResult->getFalseyScope(); - } else { - $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); } $breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class); @@ -1870,12 +1877,11 @@ public function processStmtNode( foreach ($stmt->cond as $condExpr) { $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); $initScope = $condResult->getScope(); - $condResultScope = $condResult->getScope(); // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } @@ -1924,10 +1930,12 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($initScope); $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); + $lastCondResult = null; if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); - $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); + $lastCondResult = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = $alwaysIterates->and($lastCondResult->getType()->toBoolean()->isTrue()); + $bodyScope = $lastCondResult->getTruthyScope(); + $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope, $storage); } $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -1943,7 +1951,7 @@ public function processStmtNode( $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { - $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + $finalScope = $this->filterByFalseyValueUsingResult($finalScope, $lastCondResult, $lastCondExpr); } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); @@ -2049,7 +2057,7 @@ public function processStmtNode( } } - $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + $exhaustive = $condResult->getTypeOnScope($scopeForBranches) instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; @@ -2302,7 +2310,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $scope->getType($var->var); + $varType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($var->var); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2433,7 +2441,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } else { $constantName = new Name\FullyQualified($const->name->toString()); } - $scope = $scope->assignExpression(new ConstFetch($constantName), $scope->getType($const->value), $scope->getNativeType($const->value)); + $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getType(), $constResult->getNativeType()); } } elseif ($stmt instanceof Node\Stmt\ClassConst) { $hasYield = false; @@ -2449,8 +2457,8 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } $scope = $scope->assignExpression( new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), - $scope->getType($const->value), - $scope->getNativeType($const->value), + $constResult->getType(), + $constResult->getNativeType(), ); } } elseif ($stmt instanceof Node\Stmt\EnumCase) { @@ -2669,17 +2677,61 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope, Type $exprType): ?Expr + /** + * The foreach iteratee's PHPDoc and native types on the given scope — + * memoized result asks when the scope is the iteratee's own evaluation + * scope, per-scope evaluation when it was filtered (e.g. by the + * always-iterable comparison). + * + * @return array{Type, Type} + */ + private function getForeachIterateeTypes(ExpressionResult $condResult, MutatingScope $originalScope): array + { + if ($originalScope === $condResult->getScope()) { + return [$condResult->getType(), $condResult->getNativeType()]; + } + + $promotedScope = $originalScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return [ + $condResult->getTypeOnScope($originalScope), + $condResult->getTypeOnScope($promotedScope), + ]; + } + + /** + * Filters a scope by a condition's falseyness using the condition's own + * ExpressionResult — for engine sites where the filtered scope is NOT the + * result's scope (e.g. the post-body scope of a loop filtered by the + * pre-body condition). Not-yet-migrated conditions take the guarded + * old-world dispatcher (PHPSTAN_FNSR=0). + */ + private function filterByFalseyValueUsingResult(MutatingScope $scope, ExpressionResult $condResult, Expr $cond): MutatingScope + { + if ($condResult->hasSpecifiedTypesCallback()) { + return $scope->applySpecifiedTypes( + $condResult->getSpecifiedTypes($scope, TypeSpecifierContext::createFalsey()), + $condResult->getExprResultsForApply(), + ); + } + + return $scope->filterByFalseyValue($cond); + } + + private function findEarlyTerminatingExpr(Expr $expr, MutatingScope $scope, Node\Stmt $stmt, ExpressionResultStorage $storage, Type $exprType): ?Expr { if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { if ($expr instanceof MethodCall) { - $methodCalledOnType = $scope->getType($expr->var); + $methodCalledOnType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($expr->var); } else { if ($expr->class instanceof Name) { $methodCalledOnType = $scope->resolveTypeByName($expr->class); } else { - $methodCalledOnType = $scope->getType($expr->class); + $methodCalledOnType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($expr->class); } } @@ -4086,14 +4138,14 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + $scope->toResultAwareScope([], $this, $stmt, $storage)->getNativeType($variableNode), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $scope->getType($defaultExpr); + $originalType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($defaultExpr); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -4156,6 +4208,8 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames, */ private function tryProcessUnrolledConstantArrayForeach( Foreach_ $stmt, + Type $iterateeType, + Type $nativeIterateeType, MutatingScope $originalScope, ExpressionResultStorage $originalStorage, StatementContext $context, @@ -4171,7 +4225,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $iterateeType = $originalScope->getType($stmt->expr); if (!$iterateeType->isConstantArray()->yes()) { return null; } @@ -4197,7 +4250,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; @@ -4333,7 +4385,7 @@ private function tryProcessUnrolledConstantArrayForeach( $prevLoopScope = $loopScope; $iterStorage = $originalStorage->duplicate(); $iterBodyScope = $loopScope->mergeWith($endScope); - $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, new NoopNodeCallback()); $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $loopScope = $iterBodyScopeResult->getScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -4358,9 +4410,8 @@ private function tryProcessUnrolledConstantArrayForeach( return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys]; } - private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint + private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee, Type $exprType): ?InternalThrowPoint { - $exprType = $scope->getType($iteratee); $traversableType = new ObjectType(Traversable::class); if ($traversableType->isSuperTypeOf($exprType)->no()) { @@ -4392,13 +4443,12 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, callable $nodeCallback): MutatingScope + private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Type $iterateeType, Type $nativeIterateeType, Foreach_ $stmt, callable $nodeCallback): MutatingScope { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } - $iterateeType = $originalScope->getType($stmt->expr); if ( ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) @@ -4407,6 +4457,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $scope = $scope->enterForeach( $originalScope, $stmt->expr, + $iterateeType, + $nativeIterateeType, $stmt->valueVar->name, $keyVarName, $stmt->byRef, @@ -4428,7 +4480,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto if ( $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ) { - $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $stmt->keyVar->name); + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name); $vars[] = $stmt->keyVar->name; } elseif ($stmt->keyVar !== null) { $scope = $this->processVirtualAssign( @@ -4497,10 +4549,11 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $args = $stmt->expr->getArgs(); if (count($args) >= 1) { $arrayArg = $args[0]->value; + $arrayArgAdapterScope = $scope->toResultAwareScope([], $this, $stmt, $storage); $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $scope->getType($arrayArg)->getIterableValueType(), - $scope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } @@ -4793,7 +4846,11 @@ public function processCalledMethod(MethodReflection $methodReflection): ?Mutati $statementResult = $executionEnd->getStatementResult(); $endNode = $executionEnd->getNode(); if ($endNode instanceof Node\Stmt\Expression) { - $exprType = $statementResult->getScope()->getType($endNode->expr); + $endNodeScope = $statementResult->getScope(); + if (!$endNodeScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $exprType = $endNodeScope->toResultAwareScope([], $this, $endNode, new ExpressionResultStorage())->getType($endNode->expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5147,7 +5204,7 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): return $stmts; } - private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope, ExpressionResultStorage $storage): MutatingScope { // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} @@ -5178,12 +5235,13 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayArgAdapterScope = $bodyScope->toResultAwareScope([], $this, $stmt, $storage); + $arrayType = $arrayArgAdapterScope->getType($arrayArg); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } @@ -5203,12 +5261,13 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayArgAdapterScope = $bodyScope->toResultAwareScope([], $this, $stmt, $storage); + $arrayType = $arrayArgAdapterScope->getType($arrayArg); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index c3dffe04f8..bf9873d9ae 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -801,6 +801,79 @@ public function ternaryAssignUntrackedEntries(Holder $h): void } } + /** + * Statement-level condition handling goes through the conditions' + * ExpressionResults (NodeScopeResolver getType sweep): elseif chains, + * loop conditions and exits, foreach value/key, switch exhaustiveness. + */ + public function statementIfElseIfChain(bool $a, bool $b): void + { + if ($a) { + assertType('true', $a); + } elseif ($b) { + assertType('false', $a); + assertType('true', $b); + } else { + assertType('false', $b); + } + } + + public function statementWhile(?int $i): void + { + while ($i) { + assertType('int|int<1, max>', $i); + $i = 0; + } + // the test config has polluteScopeWithLoopInitialAssignments=false, + // so the loop-exit merge keeps the tight 0|null + assertType('0|null', $i); + } + + public function statementDoWhile(bool $b): void + { + do { + $x = 1; + } while ($b); + assertType('1', $x); + } + + public function statementFor(bool $b): void + { + for ($j = 0; $b; $j++) { + assertType('true', $b); + } + assertType('int<0, max>', $j); + } + + public function statementAlwaysTrueWhile(): void + { + $k = 0; + while (1) { + $k++; + if ($k) { + break; + } + } + assertType('1', $k); + } + + /** @param non-empty-array $items */ + public function statementForeach(array $items): void + { + foreach ($items as $key => $value) { + assertType('int', $key); + assertType('string', $value); + } + } + + public function statementSwitchDefault(int $i): void + { + switch ($i) { + default: + assertType('int', $i); + } + } + private function name(): string { return 'x'; From 7e27e5c7a418300324eaa60527f64378fdac230b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:45:21 +0200 Subject: [PATCH 22/50] Migrate ConstFetchHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolveType body was already guard-safe (literals, holder-tracked runtime constants, ConstantResolver) — the typeCallback is its copy; narrowing is the type-free default. Unblocks true/false/null literals in every later migration meter. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- .../ExprHandler/ConstFetchHandler.php | 56 ++++++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 15 +++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 758f0b0ff8..4c5024e60d 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -296,7 +296,7 @@ as factual comments at their call sites, not here. - [ ] CloneHandler - [ ] ClosureHandler - [ ] CoalesceHandler -- [ ] ConstFetchHandler +- [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing - [ ] EmptyHandler - [ ] ErrorSuppressHandler - [ ] EvalHandler diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index ba5ae2c71e..136063fbf0 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\NullType; @@ -33,6 +35,7 @@ final class ConstFetchHandler implements ExprHandler public function __construct( private ConstantResolver $constantResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -52,11 +55,60 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->resolveConstFetchType($s, $e), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } + /** + * New-world copy of resolveType(): true/false/null literals, then + * holder-tracked runtime constants, then the ConstantResolver — all + * unguarded reads. + */ + private function resolveConstFetchType(MutatingScope $scope, Expr $expr): Type + { + if (!$expr instanceof ConstFetch) { + throw new ShouldNotHappenException(); + } + + $constName = (string) $expr->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } + + $namespacedName = null; + if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { + $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); + } + $globalName = new FullyQualified($expr->name->toString()); + + foreach ([$namespacedName, $globalName] as $name) { + if ($name === null) { + continue; + } + $constFetch = new ConstFetch($name); + if ($scope->hasExpressionType($constFetch)->yes()) { + return $this->constantResolver->resolveConstantType( + $name->toString(), + $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), + ); + } + } + + $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); + if ($constantType !== null) { + return $constantType; + } + + return new ErrorType(); + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $constName = (string) $expr->name; diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index bf9873d9ae..93c6c209a1 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -874,6 +874,21 @@ public function statementSwitchDefault(int $i): void } } + public function constFetchLiterals(bool $b): void + { + assertType('true', true); + assertType('false', false); + assertType('null', null); + assertType('2147483647|9223372036854775807', PHP_INT_MAX); + assertType('bool', $b && true); + assertType('bool', $b || false); + assertType('1', true ? 1 : 2); + assertType('2', false ?: 2); + if ($b !== false) { + assertType('true', $b); + } + } + private function name(): string { return 'x'; From b41e1399365c0593b5bbdcd596a4763051db80a5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:47:46 +0200 Subject: [PATCH 23/50] Migrate UnaryMinusHandler to the new world The InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12), bridging only for expressions it has no result for; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- .../ExprHandler/UnaryMinusHandler.php | 22 +++++++++++++++++++ tests/PHPStan/Analyser/data/new-world.php | 9 ++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 4c5024e60d..693586b6c5 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -327,7 +327,7 @@ as factual comments at their call sites, not here. - [ ] StaticPropertyFetchHandler - [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) - [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback -- [ ] UnaryMinusHandler +- [x] UnaryMinusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [ ] UnaryPlusHandler - [x] VariableHandler — dynamic variable names bridge - [ ] YieldFromHandler diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 2bc2d87238..612f2a713f 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class UnaryMinusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof UnaryMinus) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getUnaryMinusType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 93c6c209a1..00073c14f1 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -889,6 +889,15 @@ public function constFetchLiterals(bool $b): void } } + public function unaryMinus(int $i, float $f): void + { + assertType('-5', -5); + assertType('int', -$i); + assertType('float', -$f); + assertType('7', -(-7)); + assertType('bool', (bool) -$i); + } + private function name(): string { return 'x'; From a941cac2f59878e829e9bbaa02d7c44b5243022f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:49:46 +0200 Subject: [PATCH 24/50] Migrate UnaryPlusHandler to the new world Same shape as UnaryMinusHandler: the InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12); narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/UnaryPlusHandler.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 676110b904..2ff7da26fe 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class UnaryPlusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof UnaryPlus) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getUnaryPlusType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From ead55f1f18adabe5b32ea1e058eca8c21157408b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:49:46 +0200 Subject: [PATCH 25/50] Migrate BitwiseNotHandler to the new world Same shape as UnaryMinusHandler: the InitializerExprTypeResolver getTypeCallback asks the child's ExpressionResult first (NEW_WORLD.md paragraph 3.12); narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 4 ++-- .../ExprHandler/BitwiseNotHandler.php | 22 +++++++++++++++++++ tests/PHPStan/Analyser/data/new-world.php | 13 +++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 693586b6c5..89529287c7 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -286,7 +286,7 @@ as factual comments at their call sites, not here. - [x] AssignHandler — Ternary/Match conditional-expression holders stay old-world until those handlers migrate - [ ] AssignOpHandler - [ ] BinaryOpHandler — `typeCallback` done (Identical/NotIdentical bridge until the equality migration); `specifyTypesCallback` missing -- [ ] BitwiseNotHandler +- [x] BitwiseNotHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters - [x] BooleanNotHandler — typeCallback folds via the inner result; incremental branch scopes (truthy(!X) = X's falsey scope, §3.13); specifyTypesCallback negates the context onto the inner result (unseeded adapter for unmigrated inner) - [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters @@ -328,7 +328,7 @@ as factual comments at their call sites, not here. - [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) - [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback - [x] UnaryMinusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing -- [ ] UnaryPlusHandler +- [x] UnaryPlusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [x] VariableHandler — dynamic variable names bridge - [ ] YieldFromHandler - [ ] YieldHandler diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index de49fb0988..3f87861b8a 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class BitwiseNotHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof BitwiseNot) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getBitwiseNotType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 00073c14f1..972b7860d8 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -898,6 +898,19 @@ public function unaryMinus(int $i, float $f): void assertType('bool', (bool) -$i); } + public function unaryPlus(int $i): void + { + assertType('5', +5); + assertType('int', +$i); + assertType('3', +'3'); + } + + public function bitwiseNot(int $i): void + { + assertType('-6', ~5); + assertType('int', ~$i); + } + private function name(): string { return 'x'; From 9065e5f5b57cce7c7be5c2f19d0761c71a0a32a9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:53:05 +0200 Subject: [PATCH 26/50] Migrate PrintHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit print always evaluates to 1 — the typeCallback is the constant; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/PrintHandler.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index cf2cfd748b..836f2ba3ec 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,7 @@ final class PrintHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -63,6 +65,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + expr: $expr, + typeCallback: static fn (): Type => new ConstantIntegerType(1), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From 6307fc98e5e797165941c7a7bde58e71e0dd8d0e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:53:05 +0200 Subject: [PATCH 27/50] Migrate CloneHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback intersects the inner result's type with object and maps it through CloneTypeTraverser — the resolveType copy with the result swap; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/CloneHandler.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 9d2f1bf657..9032fa958f 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -29,6 +30,10 @@ final class CloneHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Clone_; @@ -44,6 +49,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + $cloneType = TypeCombinator::intersect($exprResult->getTypeForScope($s), new ObjectWithoutClassType()); + return TypeTraverser::map($cloneType, new CloneTypeTraverser()); + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From 7c2715ddc110e492a5c43e0fb3f7fc80fddcdb44 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:53:05 +0200 Subject: [PATCH 28/50] Migrate ExitHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exit()/die() never produce a value — the typeCallback is the constant NonAcceptingNeverType; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/ExitHandler.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 7734d8dea3..906fad57b0 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -28,6 +29,10 @@ final class ExitHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Exit_; @@ -57,6 +62,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: static fn (): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From fd2f8ff7b9d274d157c407c479bab1f2d0f6e21d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:53:05 +0200 Subject: [PATCH 29/50] Migrate ErrorSuppressHandler to the new world @ is transparent: the type, narrowing, and branch scopes all delegate to the suppressed expression's ExpressionResult; a not-yet-migrated inner takes the old-world dispatcher with an unseeded adapter. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 8 ++--- .../ExprHandler/ErrorSuppressHandler.php | 32 +++++++++++++++++++ tests/PHPStan/Analyser/data/new-world.php | 24 ++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 89529287c7..d3d9b5f887 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -293,14 +293,14 @@ as factual comments at their call sites, not here. - [ ] CastHandler - [ ] CastStringHandler - [ ] ClassConstFetchHandler -- [ ] CloneHandler +- [x] CloneHandler — typeCallback intersects the inner result with object and maps through CloneTypeTraverser; default narrowing - [ ] ClosureHandler - [ ] CoalesceHandler - [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing - [ ] EmptyHandler -- [ ] ErrorSuppressHandler +- [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner - [ ] EvalHandler -- [ ] ExitHandler +- [x] ExitHandler — constant NonAcceptingNeverType typeCallback; default narrowing - [ ] FirstClassCallableFuncCallHandler - [ ] FirstClassCallableMethodCallHandler - [ ] FirstClassCallableNewHandler @@ -320,7 +320,7 @@ as factual comments at their call sites, not here. - [x] PostIncHandler - [x] PreDecHandler - [x] PreIncHandler -- [ ] PrintHandler +- [x] PrintHandler — constant `1` typeCallback; default narrowing - [x] PropertyFetchHandler — one-level short-circuit propagation from a nullsafe var; dynamic names bridge - [x] ScalarHandler - [ ] StaticCallHandler diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 0e2e47fee9..bc2efde521 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -16,6 +16,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +26,10 @@ final class ErrorSuppressHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ErrorSuppress; @@ -42,9 +47,36 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), truthyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), falseyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + expr: $expr, + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $exprResult->getTypeForScope($s), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult), ); } + /** + * The suppressed expression's narrowing as-is; a not-yet-migrated inner + * takes the old-world dispatcher with an unseeded adapter (the inner must + * be evaluated on the ask scope, NEW_WORLD.md paragraph 3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult): SpecifiedTypes { + if (!$e instanceof ErrorSuppress) { + throw new ShouldNotHappenException(); + } + + if ($exprResult->hasSpecifiedTypesCallback()) { + return $exprResult->getSpecifiedTypes($s, $ctx)->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $e->expr, $ctx)->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { return $scope->getType($expr->expr); diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 972b7860d8..21d3596bfc 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -911,6 +911,30 @@ public function bitwiseNot(int $i): void assertType('int', ~$i); } + public function printExpr(): void + { + assertType('1', print 'x'); + } + + public function cloneExpr(Holder $h): void + { + assertType(Holder::class, clone $h); + } + + public function errorSuppress(?int $i): void + { + assertType('int|null', @$i); + if (@$i) { + assertType('int|int<1, max>', $i); + } + } + + public function exitInTernary(bool $b): void + { + $b ? exit(1) : null; + assertType('false', $b); + } + private function name(): string { return 'x'; From ca003a2e72ffd3201c9f7a19ed5871a4a7b2b3c6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:57:19 +0200 Subject: [PATCH 30/50] Migrate EvalHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eval() evaluates to mixed — constant typeCallback, default narrowing. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/EvalHandler.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index c2e635c488..7255d19a2e 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -29,6 +30,10 @@ final class EvalHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Eval_; @@ -50,6 +55,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), + expr: $expr, + typeCallback: static fn (): Type => new MixedType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From f4fd559948488423602b5612c0ed2ad0df49fb28 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:57:19 +0200 Subject: [PATCH 31/50] Migrate IncludeHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit include/require evaluate to mixed — constant typeCallback, default narrowing. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/IncludeHandler.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 788a7c1652..d505a588c9 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -30,6 +31,10 @@ final class IncludeHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Include_; @@ -52,6 +57,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), + expr: $expr, + typeCallback: static fn (): Type => new MixedType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From 97ea4a2d9164cc1dc8bf2ac470fdbf4ea69e30e6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:57:19 +0200 Subject: [PATCH 32/50] Migrate CastStringHandler to the new world The cast type goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12); narrowing keeps the old "!= ''" synthetic, processed through the migrated handlers on demand via an unseeded ResultAwareScope. Co-Authored-By: Claude Opus 4.8 --- .../ExprHandler/CastStringHandler.php | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 23c24a6ca2..e2e47c83b1 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; use function array_merge; @@ -33,6 +34,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -60,11 +62,46 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult), + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Cast\String_) { + throw new ShouldNotHappenException(); + } + + // the old synthetic, processed through the migrated handlers on + // demand (ResultAwareScope tier 4, unseeded — §3.13) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition( + $adapterScope, + new NotEqual($e->expr, new String_('')), + $ctx, + )->setRootExpr($e); + }, ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getCastType($e, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); From a742db610cebc0ab4f31ae5fedf241a98e63f3ab Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 22:57:20 +0200 Subject: [PATCH 33/50] Migrate CastHandler to the new world The cast type goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12), with the Unset_ cast as a constant null; bool/int/double cast narrowing keeps the old comparison synthetics, processed through the migrated handlers on demand via an unseeded ResultAwareScope. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 8 +-- src/Analyser/ExprHandler/CastHandler.php | 66 ++++++++++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 23 ++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index d3d9b5f887..906af90aa1 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -290,8 +290,8 @@ as factual comments at their call sites, not here. - [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters - [x] BooleanNotHandler — typeCallback folds via the inner result; incremental branch scopes (truthy(!X) = X's falsey scope, §3.13); specifyTypesCallback negates the context onto the inner result (unseeded adapter for unmigrated inner) - [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters -- [ ] CastHandler -- [ ] CastStringHandler +- [x] CastHandler — §3.12 results-first cast type (Unset_ cast → null); bool/int/double narrowing via the old comparison synthetics through an unseeded adapter +- [x] CastStringHandler — §3.12 results-first cast type; narrowing via the `!= ''` synthetic through an unseeded adapter - [ ] ClassConstFetchHandler - [x] CloneHandler — typeCallback intersects the inner result with object and maps through CloneTypeTraverser; default narrowing - [ ] ClosureHandler @@ -299,14 +299,14 @@ as factual comments at their call sites, not here. - [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing - [ ] EmptyHandler - [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner -- [ ] EvalHandler +- [x] EvalHandler — constant mixed typeCallback; default narrowing - [x] ExitHandler — constant NonAcceptingNeverType typeCallback; default narrowing - [ ] FirstClassCallableFuncCallHandler - [ ] FirstClassCallableMethodCallHandler - [ ] FirstClassCallableNewHandler - [ ] FirstClassCallableStaticCallHandler - [x] FuncCallHandler — dynamic-name calls bridge -- [ ] IncludeHandler +- [x] IncludeHandler — constant mixed typeCallback; default narrowing - [ ] InstanceofHandler - [ ] InterpolatedStringHandler - [ ] IssetHandler diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index cbc3d70fcb..de38856bad 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -23,6 +24,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -35,6 +37,8 @@ final class CastHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -55,11 +59,69 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt), ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + if ($e instanceof Cast\Unset_) { + return new NullType(); + } + + return $this->initializerExprTypeResolver->getCastType($e, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + } + + /** + * New-world copy of specifyTypes(): the old comparison synthetics, processed + * through the migrated handlers on demand (ResultAwareScope tier 4, + * unseeded — §3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + $conditionExpr = null; + if ($e instanceof Cast\Bool_) { + $conditionExpr = new Equal($e->expr, new ConstFetch(new FullyQualified('true'))); + } elseif ($e instanceof Cast\Int_) { + $conditionExpr = new NotEqual($e->expr, new Int_(0)); + } elseif ($e instanceof Cast\Double) { + $conditionExpr = new NotEqual($e->expr, new Float_(0.0)); + } + + if ($conditionExpr === null) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $conditionExpr, $ctx)->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr instanceof Cast\Unset_) { diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 21d3596bfc..7f05792f07 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -935,6 +935,29 @@ public function exitInTernary(bool $b): void assertType('false', $b); } + public function casts(?int $i, string $s): void + { + assertType("''|decimal-int-string", (string) $i); + assertType('int', (int) $s); + assertType('bool', (bool) $i); + assertType('float', (float) $i); + assertType('array{}|array{int}', (array) $i); + assertType('stdClass', (object) $i); + assertType('1', (int) true); + assertType("'5'", (string) 5); + } + + /** cast narrowing via the old comparison synthetics through the adapter */ + public function castNarrowing(?int $i, string $s): void + { + if ((bool) $i) { + assertType('int|int<1, max>', $i); + } + if ((string) $s) { + assertType("non-empty-string", $s); + } + } + private function name(): string { return 'x'; From 613f5138fad82c8cbf3eb6953d96051688356ce4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:02:48 +0200 Subject: [PATCH 34/50] Migrate ClassConstFetchHandler to the new world The typeCallback goes through the results-first InitializerExprTypeResolver callback (NEW_WORLD.md paragraph 3.12) with the dynamic class expression answered by its ExpressionResult; dynamic constant names stay mixed; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- .../ExprHandler/ClassConstFetchHandler.php | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 25199ed14f..97069084f7 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use function array_merge; @@ -31,6 +33,7 @@ final class ClassConstFetchHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -61,6 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -89,8 +93,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($classResult): Type { + if (!$e instanceof ClassConstFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof Identifier) { + return new MixedType(); + } + + return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( + $e->class, + $e->name->name, + $s->isInClass() ? $s->getClassReflection() : null, + static function (Expr $inner) use ($e, $classResult, $s): Type { + if ($classResult !== null && $inner === $e->class) { + return $classResult->getTypeForScope($s); + } + + return $s->getType($inner); + }, + ); + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From 572065e654fbc471ededd7dd542cbd8783c29204 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:02:48 +0200 Subject: [PATCH 35/50] Migrate InterpolatedStringHandler to the new world Each part's type is captured at its own evaluation point in the sequence (per-part ExpressionResults keyed by spl_object_id, the ArrayHandler pattern) and folded through resolveConcatType; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 4 +-- .../ExprHandler/InterpolatedStringHandler.php | 36 +++++++++++++++++++ tests/PHPStan/Analyser/data/new-world.php | 11 ++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 906af90aa1..6c9d97be75 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -292,7 +292,7 @@ as factual comments at their call sites, not here. - [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters - [x] CastHandler — §3.12 results-first cast type (Unset_ cast → null); bool/int/double narrowing via the old comparison synthetics through an unseeded adapter - [x] CastStringHandler — §3.12 results-first cast type; narrowing via the `!= ''` synthetic through an unseeded adapter -- [ ] ClassConstFetchHandler +- [x] ClassConstFetchHandler — §3.12 results-first class-const type (dynamic class expr via the class result); dynamic const names mixed; default narrowing - [x] CloneHandler — typeCallback intersects the inner result with object and maps through CloneTypeTraverser; default narrowing - [ ] ClosureHandler - [ ] CoalesceHandler @@ -308,7 +308,7 @@ as factual comments at their call sites, not here. - [x] FuncCallHandler — dynamic-name calls bridge - [x] IncludeHandler — constant mixed typeCallback; default narrowing - [ ] InstanceofHandler -- [ ] InterpolatedStringHandler +- [x] InterpolatedStringHandler — per-part results keyed by spl_object_id (each captured at its own evaluation point); concat folding via resolveConcatType; default narrowing - [ ] IssetHandler - [ ] MatchHandler - [ ] MethodCallHandler diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 8d6a983d29..3d3eac3dda 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -19,9 +20,11 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use function array_merge; +use function spl_object_id; /** * @implements ExprHandler @@ -33,6 +36,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -48,11 +52,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $partResults = []; foreach ($expr->parts as $part) { if (!$part instanceof Expr) { continue; } $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $partResults[spl_object_id($part)] = $partResult; $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); @@ -65,12 +71,42 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $partResult->getScope(); } + // each part type was captured at its own evaluation point in the sequence + $typeCallback = function (Expr $e, MutatingScope $s) use ($partResults): Type { + if (!$e instanceof InterpolatedString) { + throw new ShouldNotHappenException(); + } + + $resultType = new ConstantStringType(''); + $first = true; + foreach ($e->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $partType = new ConstantStringType($part->value); + } else { + $partResult = $partResults[spl_object_id($part)] ?? null; + $partType = ($partResult !== null ? $partResult->getTypeForScope($s) : $s->getType($part))->toString(); + } + if ($first) { + $resultType = $partType; + $first = false; + continue; + } + + $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); + } + + return $resultType; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 7f05792f07..1f24598961 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -958,6 +958,15 @@ public function castNarrowing(?int $i, string $s): void } } + public function classConstFetch(string $name, int $i): void + { + assertType("'CONST'", Holder::TEST_CONST); + assertType("'NewWorldTypeInference\\\\Holder'", Holder::class); + assertType('non-falsy-string', "prefix-{$name}"); + assertType('lowercase-string&non-falsy-string', "n={$i}!"); + assertType('non-empty-string', "{$name}{$i}"); + } + private function name(): string { return 'x'; @@ -971,6 +980,8 @@ private function name(): string class Holder { + public const TEST_CONST = 'CONST'; + public int $count = 0; public string $name = ''; From 382caba4fc2a0df9401a7ed681874256c5e93bec Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:06:24 +0200 Subject: [PATCH 36/50] Migrate InstanceofHandler to the new world The typeCallback folds the check from the target and class-expression results; the specifyTypesCallback is the old narrowing math with TypeSpecifier::create() resolving its null/purity gates through an adapter seeded with the target and class results (the FuncCall self-seeding precedent). instanceof arms now compose through the migrated boolean and ternary handlers under the migration meter. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- .../ExprHandler/InstanceofHandler.php | 142 +++++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 29 ++++ 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 6c9d97be75..f48e95e946 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -307,7 +307,7 @@ as factual comments at their call sites, not here. - [ ] FirstClassCallableStaticCallHandler - [x] FuncCallHandler — dynamic-name calls bridge - [x] IncludeHandler — constant mixed typeCallback; default narrowing -- [ ] InstanceofHandler +- [x] InstanceofHandler — typeCallback folds via the target/class results; specifyTypesCallback is the old create() math with an adapter seeded with the target and class results - [x] InterpolatedStringHandler — per-part results keyed by spl_object_id (each captured at its own evaluation point); concat folding via resolveConcatType; default narrowing - [ ] IssetHandler - [ ] MatchHandler diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 36933e466b..fca5dccda2 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; @@ -38,6 +39,10 @@ final class InstanceofHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Instanceof_; @@ -51,6 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $isAlwaysTerminating = $exprResult->isAlwaysTerminating(); $scope = $exprResult->getScope(); + $classResult = null; if (!$expr->class instanceof Name) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -66,11 +72,143 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult, $classResult), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult, $classResult), ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult, ?ExpressionResult $classResult): callable + { + return static function (Expr $e, MutatingScope $s) use ($exprResult, $classResult): Type { + if (!$e instanceof Instanceof_) { + throw new ShouldNotHappenException(); + } + + $expressionType = $exprResult->getTypeForScope($s); + if ( + $s->isInTrait() + && TypeUtils::findThisType($expressionType) !== null + ) { + return new BooleanType(); + } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } + + $uncertainty = false; + + if ($e->class instanceof Name) { + $unresolvedClassName = $e->class->toString(); + if ( + strtolower($unresolvedClassName) === 'static' + && $s->isInClass() + ) { + $classType = new StaticType($s->getClassReflection()); + } else { + $className = $s->resolveName($e->class); + $classType = new ObjectType($className); + } + } else { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $result = $classResult->getTypeForScope($s)->toObjectTypeForInstanceofCheck(); + $classType = $result->type; + $uncertainty = $result->uncertainty; + } + + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } + + $isSuperType = $classType->isSuperTypeOf($expressionType); + + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + } + + /** + * New-world copy of specifyTypes(): TypeSpecifier::create() resolves its + * null/purity gates through an adapter seeded with the target and class + * results (the FuncCall self-seeding precedent). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult, ?ExpressionResult $classResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult, $classResult): SpecifiedTypes { + if (!$e instanceof Instanceof_) { + throw new ShouldNotHappenException(); + } + + $exprNode = $e->expr; + $exprResults = [$s->getNodeKey($exprNode) => $exprResult]; + if ($classResult !== null && $e->class instanceof Expr) { + $exprResults[$s->getNodeKey($e->class)] = $classResult; + } + $adapterScope = $s->toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if ($e->class instanceof Name) { + $className = (string) $e->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $s->isInClass()) { + $type = new ObjectType($s->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $s->isInClass()) { + $type = new StaticType($s->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $s->isInClass() + && $s->getClassReflection()->getParentClass() !== null + ) { + $type = new ObjectType($s->getClassReflection()->getParentClass()->getName()); + } else { + $type = new NonexistentParentClassType(); + } + } else { + $type = new ObjectType($className); + } + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } + + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $result = $classResult->getTypeForScope($s)->toObjectTypeForInstanceofCheck(); + $type = $result->type; + $uncertainty = $result->uncertainty; + + if (!$type->isSuperTypeOf(new MixedType())->yes()) { + if ($ctx->true()) { + $type = TypeCombinator::intersect( + $type, + new ObjectWithoutClassType(), + ); + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } elseif ($ctx->false() && !$uncertainty) { + $exprType = $exprResult->getTypeForScope($s); + if (!$type->isSuperTypeOf($exprType)->yes()) { + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } + } + } + if ($ctx->true()) { + return $this->typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $ctx, $adapterScope)->setRootExpr($exprNode); + } + + return (new SpecifiedTypes([], []))->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $expressionType = $scope->getType($expr->expr); diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 1f24598961..1037cfb750 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -958,6 +958,35 @@ public function castNarrowing(?int $i, string $s): void } } + public function instanceofChecks(mixed $m, Holder $h, object $o): void + { + assertType('bool', $m instanceof Holder); + assertType('true', $h instanceof Holder); + assertType('false', $h instanceof AssertedClass); + if ($m instanceof Holder && $o instanceof AssertingInterface) { + assertType(Holder::class, $m); + assertType(AssertingInterface::class, $o); + } + if ($m instanceof Holder || $m instanceof AssertedClass) { + assertType('NewWorldTypeInference\\AssertedClass|NewWorldTypeInference\\Holder', $m); + } + if (!($m instanceof Holder)) { + assertType('mixed~NewWorldTypeInference\\Holder', $m); + } + assertType('NewWorldTypeInference\\Holder|null', $m instanceof Holder ? $m : null); + } + + /** @param class-string $cls */ + public function instanceofDynamic(mixed $m, string $cls, object $obj): void + { + if ($m instanceof $cls) { + assertType(Holder::class, $m); + } + if ($m instanceof $obj) { + assertType('object', $m); + } + } + public function classConstFetch(string $name, int $i): void { assertType("'CONST'", Holder::TEST_CONST); From f5659d959eb96efdfb7c02370e23266901c0a994 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:16:26 +0200 Subject: [PATCH 37/50] Migrate BinaryOpHandler narrowing to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equality milestone. The specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded ResultAwareScope — the ~1300 lines of equality/comparison narrowing (EqualityTypeSpecifying- Helper, count/strlen/preg_match patterns, range inference) stay a single source instead of a copied dual-maintenance burden; re-entering through the specifyTypesInCondition dispatcher would bounce off the head-check back into the callback. Inner synthetics (BooleanNot(Identical), swapped comparisons) route through the migrated handlers via the adapter's synthetic processing. The 3.0 cleanup absorbs the body into the callback. - The Identical/NotIdentical typeCallback bridge is gone: RicherScopeGetTypeHelper gains Type-taking variants (getIdenticalResultFromTypes/getNotIdenticalResultFromTypes) fed from the operand results. - The BinaryOp result carries its operands as companionResults so applySpecifiedTypes can price narrowing originals (e.g. the count() call in count($x) > 0). - resolveOriginalTypesForApply is restructured into nullable tiers and gains a synthetic-dim-fetch tier: the original of a narrowing-built $list[$index] entry derives from the resolvable var and dim types (plain non-null arrays; ArrayAccess and nullsafe chains bridge). - FiberScope::getKeepVoidType drops its guarded bridge: the keep-void re-ask uses the attributed clone, which is a synthetic expression the fiber machinery already processes on demand. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- src/Analyser/ExprHandler/BinaryOpHandler.php | 36 ++++++++-- src/Analyser/Fiber/FiberScope.php | 31 ++++++-- src/Analyser/MutatingScope.php | 74 +++++++++++++++----- src/Analyser/RicherScopeGetTypeHelper.php | 25 +++++-- tests/PHPStan/Analyser/data/new-world.php | 53 ++++++++++++++ 6 files changed, 189 insertions(+), 32 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index f48e95e946..c33c90017a 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -285,7 +285,7 @@ as factual comments at their call sites, not here. - [ ] ArrowFunctionHandler - [x] AssignHandler — Ternary/Match conditional-expression holders stay old-world until those handlers migrate - [ ] AssignOpHandler -- [ ] BinaryOpHandler — `typeCallback` done (Identical/NotIdentical bridge until the equality migration); `specifyTypesCallback` missing +- [x] BinaryOpHandler — typeCallback complete (Identical/NotIdentical via the Type-taking RicherScopeGetTypeHelper variants); specifyTypesCallback invokes the old ~1300-line equality/comparison body directly with an unseeded adapter (single source — the dispatcher round-trip would bounce; the 3.0 cleanup absorbs the body); operand companions for apply originals; the apply path derives synthetic dim-fetch originals from resolvable var+dim; FiberScope::getKeepVoidType via the attributed-clone synthetic - [x] BitwiseNotHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters - [x] BooleanNotHandler — typeCallback folds via the inner result; incremental branch scopes (truthy(!X) = X's falsey scope, §3.13); specifyTypesCallback negates the context onto the inner result (unseeded adapter for unmigrated inner) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 07ea392134..7a590a5832 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -66,6 +66,7 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -115,10 +116,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), expr: $expr, typeCallback: $typeCallback, + // the old specifyTypes() body stays the single source for the ~1300 + // lines of equality/comparison narrowing — invoked directly (the + // dispatcher round-trip would bounce off the head-check back into + // this callback) with an unseeded adapter: operand and special-case + // asks re-evaluate on the ask scope (tier 4), matching the old + // resolveType-on-ask-scope semantics; inner synthetics + // (BooleanNot(Identical), swapped comparisons) route through the + // migrated handlers. The 3.0 cleanup absorbs the body here. + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof BinaryOp) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, + // applySpecifiedTypes resolves narrowing originals for the operands + // (e.g. the count() call in `count($x) > 0`) from here + companionResults: [ + $scope->getNodeKey($expr->left) => $leftResult, + $scope->getNodeKey($expr->right) => $rightResult, + ], ); } @@ -183,10 +205,12 @@ private function resolveTypeFromResults(BinaryOp $expr, MutatingScope $scope, Ex return new BooleanType(); } - if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { - // RicherScopeGetTypeHelper resolves operands through the scope — - // guarded legacy bridge until the equality migration (PHPSTAN_FNSR=0) - return $scope->getType($expr); + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResultFromTypes($scope, $expr, $getType($expr->left), $getType($expr->right))->type; + } + + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResultFromTypes($scope, $expr, $getType($expr->left), $getType($expr->right))->type; } if ($expr instanceof BinaryOp\LogicalXor) { diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 5e6478c77e..a37888095c 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -12,6 +12,7 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; final class FiberScope extends MutatingScope { @@ -143,10 +144,32 @@ public function getNativeType(Expr $expr): Type public function getKeepVoidType(Expr $node): Type { - // keepVoid is a one-off we will solve separately — regular results store - // void as null, so falling back to them would silently lose the void. - // Guarded old-world bridge until then (PHPSTAN_FNSR=0). - return $this->toMutatingScope()->getKeepVoidType($node); + if ( + !$node instanceof Expr\Match_ + && ( + ( + !$node instanceof Expr\FuncCall + && !$node instanceof Expr\MethodCall + && !$node instanceof Expr\NullsafeMethodCall + && !$node instanceof Expr\StaticCall + ) || $node->isFirstClassCallable() + ) + ) { + return $this->getType($node); + } + + $originalType = $this->getType($node); + if (!TypeCombinator::containsNull($originalType)) { + return $originalType; + } + + // the attributed clone is a synthetic expression — the fiber suspends + // for it and the handlers honor the attribute when resolving the + // return type (VoidToNullTypeTransformer) + $clonedNode = clone $node; + $clonedNode->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, true); + + return $this->getType($clonedNode); } /** diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c68d3ccc60..1e39c748c6 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3587,30 +3587,70 @@ private function resolveOriginalTypesForApply(Expr $expr, array $exprResults): a } } - if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { - $exprString = $this->getNodeKey($expr); - if ( - array_key_exists($exprString, $this->expressionTypes) - // a Maybe-certainty holder carries the "when defined" type only; - // the original for narrowing math must match getType() semantics - // (a maybe-defined variable is still mixed) - && $this->expressionTypes[$exprString]->getCertainty()->yes() - ) { - $nativeHolder = $this->nativeExpressionTypes[$exprString] ?? $this->expressionTypes[$exprString]; + $resolved = $this->tryResolveOriginalTypesForApply($expr, $exprResults); + if ($resolved !== null) { + return $resolved; + } + + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return [$this->getType($expr), $this->getNativeType($expr)]; + } + + /** + * @param array $exprResults + * @return array{Type, Type}|null + */ + private function tryResolveOriginalTypesForApply(Expr $expr, array $exprResults): ?array + { + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return null; + } + + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->expressionTypes) + // a Maybe-certainty holder carries the "when defined" type only; + // the original for narrowing math must match getType() semantics + // (a maybe-defined variable is still mixed) + && $this->expressionTypes[$exprString]->getCertainty()->yes() + ) { + $nativeHolder = $this->nativeExpressionTypes[$exprString] ?? $this->expressionTypes[$exprString]; + return [ + TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()), + TypeUtils::resolveLateResolvableTypes($nativeHolder->getType()), + ]; + } + + if (array_key_exists($exprString, $exprResults)) { + return [$exprResults[$exprString]->getType(), $exprResults[$exprString]->getNativeType()]; + } + + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + // a dim fetch built by narrowing code (e.g. the count() index + // inference): derive the original from the resolvable var and dim — + // plain non-null arrays only, ArrayAccess and nullsafe chains bridge + $varPair = $this->tryResolveOriginalTypesForApply($expr->var, $exprResults); + if ($varPair === null) { + return null; + } + $dimPair = $this->tryResolveOriginalTypesForApply($expr->dim, $exprResults); + if ($dimPair === null) { + return null; + } + [$varType, $varNativeType] = $varPair; + [$dimType, $dimNativeType] = $dimPair; + if ($varType->isArray()->yes() && !TypeCombinator::containsNull($varType)) { return [ - TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()), - TypeUtils::resolveLateResolvableTypes($nativeHolder->getType()), + $varType->getOffsetValueType($dimType), + $varNativeType->isArray()->yes() ? $varNativeType->getOffsetValueType($dimNativeType) : new MixedType(), ]; } - if (array_key_exists($exprString, $exprResults)) { - return [$exprResults[$exprString]->getType(), $exprResults[$exprString]->getNativeType()]; - } + return null; } - // guarded legacy bridge (works under PHPSTAN_FNSR=0) - return [$this->getType($expr), $this->getNativeType($expr)]; + return null; } /** diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c187580..060041d223 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -10,6 +10,7 @@ use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Type; use PHPStan\Type\TypeResult; use function is_string; @@ -28,6 +29,17 @@ public function __construct( * @return TypeResult */ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + { + return $this->getIdenticalResultFromTypes($scope, $expr, $scope->getType($expr->left), $scope->getType($expr->right)); + } + + /** + * Type-taking variant for the new world: operand types come from their + * ExpressionResults, the scope only answers property-reflection lookups. + * + * @return TypeResult + */ + public function getIdenticalResultFromTypes(Scope $scope, Identical $expr, Type $leftType, Type $rightType): TypeResult { if ( $expr->left instanceof Variable @@ -39,9 +51,6 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult return new TypeResult(new ConstantBooleanType(true), []); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); - if ( ( $expr->left instanceof Node\Expr\PropertyFetch @@ -80,7 +89,15 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult */ public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + return $this->getNotIdenticalResultFromTypes($scope, $expr, $scope->getType($expr->left), $scope->getType($expr->right)); + } + + /** + * @return TypeResult + */ + public function getNotIdenticalResultFromTypes(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, Type $leftType, Type $rightType): TypeResult + { + $identicalResult = $this->getIdenticalResultFromTypes($scope, new Identical($expr->left, $expr->right), $leftType, $rightType); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 1037cfb750..088bda6aa7 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -958,6 +958,59 @@ public function castNarrowing(?int $i, string $s): void } } + public function equalityNarrowing(mixed $m, ?int $i, ?Holder $h, int $n): void + { + if ($i === null) { + assertType('null', $i); + } else { + assertType('int', $i); + } + if ($h !== null) { + assertType(Holder::class, $h); + } + if ($m === 'str') { + assertType("'str'", $m); + } + if ($i == null) { + assertType('0|null', $i); + } else { + assertType('int|int<1, max>', $i); + } + if ($n === 5 || $n === 6) { + assertType('5|6', $n); + } + assertType('bool', $i === null); + assertType('true', 5 === 5); + assertType('false', 5 !== 5); + } + + public function comparisonNarrowing(int $n, ?int $i): void + { + if ($n > 5) { + assertType('int<6, max>', $n); + } + if ($n <= 0) { + assertType('int', $n); + } + if ($i !== null && $i >= 10) { + assertType('int<10, max>', $i); + } + } + + /** @param list $items */ + public function countAndStrlenPatterns(array $items, int $idx, string $s): void + { + if (count($items) > 0) { + assertType('non-empty-list', $items); + } + if ($idx >= 0 && $idx < count($items)) { + assertType('string', $items[$idx]); + } + if (strlen($s) > 0) { + assertType('non-empty-string', $s); + } + } + public function instanceofChecks(mixed $m, Holder $h, object $o): void { assertType('bool', $m instanceof Holder); From 46d235d0b4e1c6b05a0377eccf5a4c5f747fd0f5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:20:56 +0200 Subject: [PATCH 38/50] Migrate ArrayDimFetchHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback composes the var and dim results: getOffsetValueType for arrays, the offsetGet() synthetic through an unseeded adapter for ArrayAccess, one-level nullsafe short-circuit propagation per NEW_WORLD.md paragraph 3.10. The write-context $x[] form types as never; narrowing is the type-free default. MutatingScope's holder-first helpers gain a scalar tier — the ArrayDimFetch parent-update in specifyExpressionType resolves literal dims context-free instead of through the guarded bridge. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- .../ExprHandler/ArrayDimFetchHandler.php | 67 +++++++++++++++++-- src/Analyser/MutatingScope.php | 8 +++ tests/PHPStan/Analyser/data/new-world.php | 23 +++++++ 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index c33c90017a..b1d7c45b80 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -280,7 +280,7 @@ as factual comments at their call sites, not here. ### Expression handlers -- [ ] ArrayDimFetchHandler +- [x] ArrayDimFetchHandler — typeCallback composes var/dim results (offsetGet synthetic via unseeded adapter for ArrayAccess; one-level nullsafe short-circuit propagation per §3.10); write-context `$x[]` is NeverType; default narrowing; holder-first helpers gained a scalar tier for the parent-update dims - [x] ArrayHandler - [ ] ArrowFunctionHandler - [x] AssignHandler — Ternary/Match conditional-expression holders stay old-world until those handlers migrate diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 1c389f9fb9..ba1fba0fc9 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -23,9 +24,12 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; /** @@ -35,6 +39,10 @@ final class ArrayDimFetchHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ArrayDimFetch; @@ -86,8 +94,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } @@ -97,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $scope = $varResult->getScope(); - $varType = $scope->getType($expr->var); + $varType = $varResult->getType(); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, @@ -109,14 +118,62 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->getThrowPoints()); } + // a nullsafe var that can be null short-circuits this fetch too; its + // handler already produced the null-union — propagate one level, no + // recursive chain walking (NEW_WORLD.md §3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($varResult): bool { + if (!$e instanceof ArrayDimFetch) { + throw new ShouldNotHappenException(); + } + + return ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varResult->getTypeForScope($s)); + }; + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $dimResult, $isShortcircuited, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof ArrayDimFetch || $e->dim === null) { + throw new ShouldNotHappenException(); + } + + $varTypeForFetch = $varResult->getTypeForScope($s); + if ($isShortcircuited($e, $s)) { + $varTypeForFetch = TypeCombinator::removeNull($varTypeForFetch); + } + + if ( + !$varTypeForFetch->isArray()->yes() + && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varTypeForFetch)->yes() + ) { + // ArrayAccess: the offsetGet() synthetic, processed on demand + // (ResultAwareScope tier 4) + $fetchedType = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType( + new MethodCall( + new TypeExpr($varTypeForFetch), + new Identifier('offsetGet'), + [ + new Arg($e->dim), + ], + ), + ); + } else { + $fetchedType = $varTypeForFetch->getOffsetValueType($dimResult->getTypeForScope($s)); + } + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($fetchedType, new NullType()); + } + + return $fetchedType; + }; + return new ExpressionResult( $scope, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1e39c748c6..2af5b41c37 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2861,6 +2861,10 @@ private function unsetExpression(Expr $expr): self */ private function getTypeFromTrackedHolder(Expr $expr): Type { + if ($expr instanceof Node\Scalar) { + return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($this)); + } + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { $exprString = $this->getNodeKey($expr); if ( @@ -2876,6 +2880,10 @@ private function getTypeFromTrackedHolder(Expr $expr): Type private function getNativeTypeFromTrackedHolder(Expr $expr): Type { + if ($expr instanceof Node\Scalar) { + return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($this)); + } + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { $exprString = $this->getNodeKey($expr); if ( diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 088bda6aa7..b109e5314e 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -958,6 +958,29 @@ public function castNarrowing(?int $i, string $s): void } } + /** + * @param array{a: int, b?: string} $shape + * @param list $list + * @param array $map + */ + public function arrayDimFetches(array $shape, array $list, array $map, int $i, string $k, ?Holder $maybe): void + { + assertType('int', $shape['a']); + assertType('string', $list[$i]); + assertType('int', $map[$k]); + assertType('int|int<1, max>', $map[$k] ?: 5); + if ($list[0] === 'x') { + assertType("'x'", $list[0]); + } + assertType('int|null', $maybe?->inner->count); + assertType('1', [1, 2][0]); + } + + public function arrayAccessFetch(\ArrayObject $ao): void + { + assertType('mixed', $ao['key']); + } + public function equalityNarrowing(mixed $m, ?int $i, ?Holder $h, int $n): void { if ($i === null) { From 5279d1ae9bf1999b63f5f413363e5b3123d83570 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:27:09 +0200 Subject: [PATCH 39/50] Migrate IssetHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback runs issetCheck() on an unseeded adapter so its expression walk resolves through ResultAwareScope tiers; the specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded adapter (the BinaryOp precedent) — the multi-isset And-chain synthetic routes through the migrated handlers. NonNullabilityHelper::ensureNonNullability gains an askScopeFactory: the pre-processing non-nullability walk prices its type asks through an adapter while specifying on the real evolving scope; null keeps the guarded direct asks (PHPSTAN_FNSR=0). All three callers (Isset, Empty, Coalesce) pass the factory. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/CoalesceHandler.php | 2 +- .../Helper/NonNullabilityHelper.php | 35 ++++++++---- src/Analyser/ExprHandler/IssetHandler.php | 55 +++++++++++++++++-- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index eb566e0166..df69aa5a1a 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -114,7 +114,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index f593acfec1..8bbf0d9269 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -21,9 +22,16 @@ final class NonNullabilityHelper { - public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + /** + * @param (callable(MutatingScope): MutatingScope)|null $askScopeFactory wraps + * the scope used for type asks (an adapter in the new world); the + * specification itself happens on the unwrapped scopes. Null keeps + * the guarded direct asks (PHPSTAN_FNSR=0). + */ + public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify, ?callable $askScopeFactory = null): EnsuredNonNullabilityResult { - $exprType = $scope->getType($exprToSpecify); + $askScope = $askScopeFactory !== null ? $askScopeFactory($scope) : $scope; + $exprType = $askScope->getType($exprToSpecify); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -33,9 +41,13 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getType($exprToSpecify); + if (!$originalScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $originalAskScope = $askScopeFactory !== null ? $askScopeFactory($originalScope) : $originalScope; + $originalExprType = $originalAskScope->getType($exprToSpecify); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->getNativeType($exprToSpecify); + $originalNativeType = $originalAskScope->getNativeType($exprToSpecify); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -53,8 +65,8 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getType($parentExpr), - $scope->getNativeType($parentExpr), + $askScope->getType($parentExpr), + $askScope->getNativeType($parentExpr), $originalScope->hasExpressionType($parentExpr), ); } @@ -65,7 +77,7 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $certainty = $hasExpressionType; } - $nativeType = $scope->getNativeType($exprToSpecify); + $nativeType = $askScope->getNativeType($exprToSpecify); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -129,12 +141,15 @@ public function ensureShallowNonNullabilityFromTypes(MutatingScope $scope, Expr ); } - public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + /** + * @param (callable(MutatingScope): MutatingScope)|null $askScopeFactory + */ + public function ensureNonNullability(MutatingScope $scope, Expr $expr, ?callable $askScopeFactory = null): EnsuredNonNullabilityResult { $specifiedExpressions = []; $originalScope = $scope; - $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope, $askScopeFactory) { + $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr, $askScopeFactory); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index da8e48431f..49e834e0ed 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -59,6 +59,7 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -350,7 +351,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nonNullabilityResults = []; $isAlwaysTerminating = false; foreach ($expr->vars as $var) { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); @@ -364,7 +365,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $scope->getType($var->var); + $varType = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($var->var); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } @@ -391,8 +392,54 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt): Type { + if (!$e instanceof Isset_) { + throw new ShouldNotHappenException(); + } + + // issetCheck() walks the expression asking for types — priced + // through an unseeded adapter (ResultAwareScope tiers) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $issetResult = true; + foreach ($e->vars as $var) { + $result = $adapterScope->issetCheck($var, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } + + continue; + } + + $issetResult = $result; + } + + if ($issetResult === null) { + return new BooleanType(); + } + + return new ConstantBooleanType($issetResult); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — invoked directly with an unseeded adapter; the + // multi-isset And-chain synthetic routes through the migrated handlers + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Isset_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } From 4478c2ff0f8e6504462092fbe1c8e2a908cc232f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:27:09 +0200 Subject: [PATCH 40/50] Migrate EmptyHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback runs issetCheck() on an unseeded adapter; the specifyTypesCallback invokes the old specifyTypes() body directly — its "!isset(X) || !X" synthetic routes through the migrated handlers via the adapter's synthetic processing. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 4 +- src/Analyser/ExprHandler/EmptyHandler.php | 48 +++++++++++++++++++++-- tests/PHPStan/Analyser/data/new-world.php | 31 +++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index b1d7c45b80..1411d90e27 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -297,7 +297,7 @@ as factual comments at their call sites, not here. - [ ] ClosureHandler - [ ] CoalesceHandler - [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing -- [ ] EmptyHandler +- [x] EmptyHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (the `!isset(X) || !X` synthetic routes through migrated handlers) - [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner - [x] EvalHandler — constant mixed typeCallback; default narrowing - [x] ExitHandler — constant NonAcceptingNeverType typeCallback; default narrowing @@ -309,7 +309,7 @@ as factual comments at their call sites, not here. - [x] IncludeHandler — constant mixed typeCallback; default narrowing - [x] InstanceofHandler — typeCallback folds via the target/class results; specifyTypesCallback is the old create() math with an adapter seeded with the target and class results - [x] InterpolatedStringHandler — per-part results keyed by spl_object_id (each captured at its own evaluation point); concat folding via resolveConcatType; default narrowing -- [ ] IssetHandler +- [x] IssetHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (BinaryOp precedent; the multi-isset And-chain synthetic routes through migrated handlers); ensureNonNullability asks priced via the askScopeFactory - [ ] MatchHandler - [ ] MethodCallHandler - [ ] NewHandler diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 185850e6e6..08cd1cc7d0 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -32,6 +32,7 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -85,7 +86,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); @@ -98,8 +99,49 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt): Type { + if (!$e instanceof Expr\Empty_) { + throw new ShouldNotHappenException(); + } + + // issetCheck() walks the expression asking for types — priced + // through an unseeded adapter (ResultAwareScope tiers) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $result = $adapterScope->issetCheck($e->expr, static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — its `!isset(X) || !X` synthetic routes through the + // migrated handlers via the adapter's synthetic processing + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Expr\Empty_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index b109e5314e..eb420d80aa 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -981,6 +981,37 @@ public function arrayAccessFetch(\ArrayObject $ao): void assertType('mixed', $ao['key']); } + /** @param array $map */ + public function issetChecks(?int $i, array $map, string $k): void + { + assertType('bool', isset($i)); + if (isset($i)) { + assertType('int', $i); + } else { + assertType('null', $i); + } + if (isset($map[$k])) { + assertType('int', $map[$k]); + } + if (isset($i, $map[$k])) { + assertType('int', $i); + } + } + + /** @param array $map */ + public function emptyChecks(?int $i, array $map): void + { + assertType('bool', empty($i)); + if (empty($i)) { + assertType('0|null', $i); + } else { + assertType('int|int<1, max>', $i); + } + if (!empty($map)) { + assertType('non-empty-array', $map); + } + } + public function equalityNarrowing(mixed $m, ?int $i, ?Holder $h, int $n): void { if ($i === null) { From e19829c1394fbfd0d556142029d6e3c668a461c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:40:44 +0200 Subject: [PATCH 41/50] Migrate CoalesceHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isset(left) synthetic is processed once through the migrated IssetHandler: its truthy narrowing is applied for the after-scope, and the right-side scope comes from the coalesce's OWN falsey narrowing (left narrowed to null when isset-certain) — the isset falsey would unset the left expression and poison its certainty (falsey-coalesce regression caught by nsrt). The typeCallback runs issetCheck on an unseeded adapter and reads the left's type on the isset-truthy scope via getTypeOnScope; the specifyTypesCallback is the old body with the right side answered by its result. Everything is UNSEEDED: the left result was evaluated on the non-nullability-ensured scope, so its memoized type is already null-stripped — seeding it as an apply companion or adapter entry poisoned narrowing originals and create()'s null gates (the isset-coalesce-empty-type regression; paragraph 3.13's evaluation-base rule re-confirmed). applySpecifiedTypes originals gain a trivial TypeExpr tier (the type is the node's payload). Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- src/Analyser/ExprHandler/CoalesceHandler.php | 117 ++++++++++++++++++- src/Analyser/MutatingScope.php | 5 + tests/PHPStan/Analyser/data/new-world.php | 13 +++ 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 1411d90e27..3bad8da0fd 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -295,7 +295,7 @@ as factual comments at their call sites, not here. - [x] ClassConstFetchHandler — §3.12 results-first class-const type (dynamic class expr via the class result); dynamic const names mixed; default narrowing - [x] CloneHandler — typeCallback intersects the inner result with object and maps through CloneTypeTraverser; default narrowing - [ ] ClosureHandler -- [ ] CoalesceHandler +- [x] CoalesceHandler — isset(left) processed once as a synthetic through the migrated IssetHandler (truthy applied, falsey replaced by the coalesce's own falsey narrowing — isset-falsey would unset the left and poison certainty); typeCallback via issetCheck on an unseeded adapter + the left result on the isset-truthy scope; UNSEEDED everywhere — the left result's memo is null-stripped by ensureNonNullability (§3.13 lesson #2 re-confirmed); TypeExpr tier in apply originals - [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing - [x] EmptyHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (the `!isset(X) || !X` synthetic routes through migrated handlers) - [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index df69aa5a1a..bbda0942d6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -9,9 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; 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; @@ -34,6 +36,8 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -112,6 +116,27 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * The coalesce's non-truthy narrowing: when the left is provably set, a + * non-truthy `left ?? right` narrows the left to null (the right side ran). + */ + private function specifyCoalesceFalseyTypes(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, MutatingScope $scope, Coalesce $expr, TypeSpecifierContext $context): SpecifiedTypes + { + $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $isset = $adapterScope->issetCheck($expr->left, static fn () => true); + + if ($isset !== true) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $expr->left, + new NullType(), + $context->negate(), + $adapterScope, + )->setRootExpr($expr); + } + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); @@ -120,23 +145,103 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); - $rightScope = $scope->filterByFalseyValue($expr); + // the isset(left) synthetic routes through the migrated IssetHandler — + // processed once, its narrowing applied instead of the guarded filters + $issetLeftExpr = new Expr\Isset_([$expr->left]); + $issetResult = $nodeScopeResolver->processExprNode($stmt, $issetLeftExpr, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep()); + // the right side runs when the left is null/unset — the coalesce's own + // falsey narrowing (left narrowed to null when isset-certain), NOT the + // isset falsey (which would unset the left and poison its certainty) + // the coalesce's own falsey narrowing — left narrowed to null when + // isset-certain. No seeds: the left result was evaluated on the + // non-nullability-ensured scope, so its memoized type is already + // null-stripped — originals must resolve from the holders (§3.13) + $rightScope = $scope->applySpecifiedTypes( + $this->specifyCoalesceFalseyTypes($nodeScopeResolver, $stmt, $scope, $expr, TypeSpecifierContext::createFalsey()), + [], + ); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); - $rightExprType = $scope->getType($expr->right); + $rightExprType = $rightResult->getType(); + $issetTruthyScope = $scope->applySpecifiedTypes( + $issetResult->getSpecifiedTypes($scope, TypeSpecifierContext::createTruthy()), + $issetResult->getExprResultsForApply(), + ); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + $scope = $issetTruthyScope; } else { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + $scope = $issetTruthyScope->mergeWith($rightResult->getScope()); } + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $condResult, $rightResult, $issetResult): Type { + if (!$e instanceof Coalesce) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $result = $adapterScope->issetCheck($e->left, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result !== null && $result !== false) { + return TypeCombinator::removeNull($condResult->getTypeOnScope($issetResult->getTruthyScope())); + } + + $rightType = $rightResult->getTypeForScope($s); + + if ($result === null) { + return TypeCombinator::union( + TypeCombinator::removeNull($condResult->getTypeOnScope($issetResult->getTruthyScope())), + $rightType, + ); + } + + return $rightType; + }; + + $specifyTypesCallback = function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $rightResult): SpecifiedTypes { + if (!$e instanceof Coalesce) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if (!$ctx->true()) { + return $this->specifyCoalesceFalseyTypes($nodeScopeResolver, $stmt, $s, $e, $ctx); + } + + if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeForScope($s)->toBoolean())->yes()) { + return $this->typeSpecifier->create( + $e->left, + new NullType(), + TypeSpecifierContext::createFalse(), + $adapterScope, + )->setRootExpr($e); + } + + // The Coalesce condition matched but produced no narrowing; the legacy + // if/elseif chain fell through to its empty-SpecifiedTypes tail here, + // not to the truthy/falsey default. + return (new SpecifiedTypes([], []))->setRootExpr($e); + }; + return new ExpressionResult( $scope, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 2af5b41c37..63983951f2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -36,6 +36,7 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\IssetExpr; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; @@ -3610,6 +3611,10 @@ private function resolveOriginalTypesForApply(Expr $expr, array $exprResults): a */ private function tryResolveOriginalTypesForApply(Expr $expr, array $exprResults): ?array { + if ($expr instanceof TypeExpr) { + return [$expr->getExprType(), $expr->getExprType()]; + } + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { return null; } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index eb420d80aa..cd67245a69 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -998,6 +998,19 @@ public function issetChecks(?int $i, array $map, string $k): void } } + /** @param array $map */ + public function coalesce(?int $i, array $map, string $k, ?string $maybe): void + { + assertType('int', $i ?? 5); + assertType("'fallback'|int", $map[$k] ?? 'fallback'); + assertType('int|string|null', $maybe ?? $i); + $x = $i ?? 0; + assertType('int', $x); + if (($i ?? 0) === 5) { + assertType('5', $i); + } + } + /** @param array $map */ public function emptyChecks(?int $i, array $map): void { From 9e6d494a1173144e94fae38815e3d6fbae69a0dc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 10 Jun 2026 23:44:07 +0200 Subject: [PATCH 42/50] Migrate StaticPropertyFetchHandler to the new world The typeCallback mirrors PropertyFetchHandler: the native-promoted path goes through the property reflection finder, the class expression is answered by its ExpressionResult, a nullsafe class expression short-circuits one level (NEW_WORLD.md paragraph 3.10), and dynamic property names keep the guarded bridge. Narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- .../StaticPropertyFetchHandler.php | 75 ++++++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 18 +++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 3bad8da0fd..28aab21547 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -324,7 +324,7 @@ as factual comments at their call sites, not here. - [x] PropertyFetchHandler — one-level short-circuit propagation from a nullsafe var; dynamic names bridge - [x] ScalarHandler - [ ] StaticCallHandler -- [ ] StaticPropertyFetchHandler +- [x] StaticPropertyFetchHandler — typeCallback mirrors PropertyFetch (native via reflection finder; class-expr via the class result; one-level nullsafe short-circuit; dynamic names bridge); default narrowing - [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) - [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback - [x] UnaryMinusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index ec36cf6438..6fa3d6cd82 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; @@ -22,9 +23,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; @@ -40,6 +43,7 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -63,6 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), ]; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -80,14 +85,80 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $nameResult->getScope(); } + // a nullsafe class expr that can be null short-circuits this fetch too — + // propagate one level (NEW_WORLD.md paragraph 3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($classResult): bool { + if (!$e instanceof StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + return $classResult !== null + && ($e->class instanceof Expr\NullsafePropertyFetch || $e->class instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($classResult->getTypeForScope($s)); + }; + $typeCallback = function (Expr $e, MutatingScope $s) use ($classResult, $isShortcircuited): Type { + if (!$e instanceof StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof VarLikeIdentifier) { + // dynamic property names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + if ($s->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($e, $s); + if ($propertyReflection === null) { + return new ErrorType(); + } + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + $nativeType = $propertyReflection->getNativeType(); + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($nativeType, new NullType()); + } + + return $nativeType; + } + + if ($e->class instanceof Name) { + $staticPropertyFetchedOnType = $s->resolveTypeByName($e->class); + } else { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $staticPropertyFetchedOnType = TypeCombinator::removeNull($classResult->getTypeForScope($s))->getObjectTypeOrClassStringObjectType(); + } + + $fetchType = $this->propertyFetchType( + $s, + $staticPropertyFetchedOnType, + $e->name->toString(), + $e, + ); + if ($fetchType === null) { + $fetchType = new ErrorType(); + } + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($fetchType, new NullType()); + } + + return $fetchType; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index cd67245a69..b382956921 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -998,6 +998,15 @@ public function issetChecks(?int $i, array $map, string $k): void } } + public function staticPropertyFetches(): void + { + assertType('int', StaticHolder::$count); + assertType('string|null', StaticHolder::$name); + if (StaticHolder::$name !== null) { + assertType('string', StaticHolder::$name); + } + } + /** @param array $map */ public function coalesce(?int $i, array $map, string $k, ?string $maybe): void { @@ -1157,6 +1166,15 @@ public function isA(): bool; } +class StaticHolder +{ + + public static int $count = 0; + + public static ?string $name = null; + +} + class AssertedClass implements AssertingInterface { From cc545f754651e360fbccb5237f577730a2125495 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:06:16 +0200 Subject: [PATCH 43/50] Type first-class callables in the NodeScopeResolver fast-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-class callables are produced before handler dispatch, so the result callbacks live in the fast-path: types resolve from reflection (getFirstClassCallableType/createFirstClassCallable) with the two scope asks — dynamic function name, method receiver — priced through unseeded adapters; narrowing is empty (a first-class callable is always a truthy Closure). processArgs now carries the per-argument ExpressionResults as companionResults on its result (with callback-less context-scope wrappers for closure and arrow-function arguments) so call handlers can seed their adapters — a passed closure's memoized type carries the parameter-type inference context that re-processing outside the call would lose. Covers FirstClassCallableFuncCall/MethodCall/New/StaticCall handlers. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExpressionResult.php | 8 ++++ src/Analyser/NodeScopeResolver.php | 61 +++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index f66f53dd0e..32363139b7 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -241,6 +241,14 @@ public function getFalseyScope(): MutatingScope return $this->scope; } + /** + * @return array + */ + public function getCompanionResults(): array + { + return $this->companionResults; + } + /** * Self + companions, keyed by node key — the pre-narrowing type sources * for applySpecifiedTypes(). diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 846945c014..da8bc35d69 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2798,10 +2798,9 @@ public function processExprNode( } $innerResult = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); - // carry the original expr, not the virtual callable node — the virtual - // node's resolveType is intentionally mixed, the first-class-callable - // handlers resolve types for the original expr (guarded legacy bridge, - // PHPSTAN_FNSR=0, until those handlers migrate) + // carry the original expr, not the virtual callable node — first-class + // callables resolve from reflection; the two scope asks (dynamic + // function name, method receiver) go through an unseeded adapter $result = new ExpressionResult( $innerResult->getScope(), hasYield: $innerResult->hasYield(), @@ -2809,6 +2808,46 @@ public function processExprNode( throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($stmt): Type { + if ($e instanceof FuncCall && $e->name instanceof Expr) { + $callableType = $s->toResultAwareScope([], $this, $stmt, new ExpressionResultStorage())->getType($e->name); + if (!$callableType->isCallable()->yes()) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + null, + $callableType->getCallableParametersAcceptors($s), + $s->nativeTypesPromoted, + ); + } + + if ($e instanceof MethodCall) { + if (!$e->name instanceof Node\Identifier) { + return new ObjectType(Closure::class); + } + + $varType = $s->toResultAwareScope([], $this, $stmt, new ExpressionResultStorage())->getType($e->var); + $method = $s->getMethodReflection($varType, $e->name->toString()); + if ($method === null) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + $method, + $method->getVariants(), + $s->nativeTypesPromoted, + ); + } + + if (!$e instanceof Expr\CallLike) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getFirstClassCallableType($e, InitializerExprContext::fromScope($s), $s->nativeTypesPromoted); + }, + // a first-class callable is always a truthy Closure — no narrowing + specifyTypesCallback: static fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => (new SpecifiedTypes([], []))->setRootExpr($e), ); $this->storeResult($storage, $expr, $result); return $result; @@ -3587,6 +3626,7 @@ public function processArgs( $processingOrder = array_keys($args); $hasReorderedArgs = false; $argExprTypes = []; + $argResults = []; foreach ($args as $arg) { if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { $hasReorderedArgs = true; @@ -3711,6 +3751,10 @@ public function processArgs( } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + // callback-less companion: the guarded bridge resolves the closure + // on its context scope (the attribute-driven parameter inference) — + // new-world closure typing lands with the ClosureHandler migration + $argResults[$scope->getNodeKey($arg->value)] = new ExpressionResult($scopeToPass, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $arg->value); $uses = []; foreach ($arg->value->uses as $use) { @@ -3769,6 +3813,7 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $argResults[$scope->getNodeKey($arg->value)] = new ExpressionResult($scopeToPass, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $arg->value); } else { $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { @@ -3777,6 +3822,7 @@ public function processArgs( $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); $exprType = $exprResult->getType(); $argExprTypes[spl_object_id($arg->value)] = $exprType; + $argResults[$scope->getNodeKey($arg->value)] = $exprResult; $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -3909,8 +3955,11 @@ public function processArgs( } } - // not storing this, it's scope after processing all args - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + // not storing this, it's scope after processing all args; the per-arg + // results ride along so call handlers can seed their adapters — a passed + // closure's memoized type carries the parameter-type inference context + // that re-processing outside the call would lose + return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, companionResults: $argResults); } /** From f679d6c82790648f2fa22d2ceab7c398531e68d2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:06:16 +0200 Subject: [PATCH 44/50] Migrate MethodCallHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback resolves the call via resolveMethodCallTypeViaResults: the receiver comes from its ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md paragraph 3.10), args and dynamic-return extensions resolve through an adapter seeded with the call's self-result (extension helpers re-asking about this call terminate — the FuncCall precedent) and the per-arg companion results (passed closures answer with their context-aware memo); dynamic method names bridge. The specifyTypesCallback invokes the old specifyTypes() body directly with an unseeded adapter (the BinaryOp precedent). The lazy return-type callback is threaded into MethodThrowPointHelper, removing the guarded explicit-never and implicit-throws asks. Co-Authored-By: Claude Opus 4.8 --- .../ExprHandler/MethodCallHandler.php | 113 +++++++++++++++++- 1 file changed, 108 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 48aa1ffefd..b4f7d734ed 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -33,11 +33,13 @@ use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -59,6 +61,7 @@ public function __construct( private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, ) @@ -163,7 +166,15 @@ public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, S $scope = $argsResult->getScope(); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $throwPointScope = $scope; + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint( + $methodReflection, + $parametersAcceptor, + $normalizedExpr, + $scope, + $context, + fn (): Type => $this->resolveMethodCallTypeViaResults($normalizedExpr, $throwPointScope, $varResult, $argsResult->getCompanionResults(), $nodeScopeResolver, $stmt), + ); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -204,14 +215,36 @@ public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, S $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + $argResults = $argsResult->getCompanionResults(); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult, $argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveMethodCallTypeViaResults($e, $s, $varResult, $argResults, $nodeScopeResolver, $stmt); + }; + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — extensions, conditional return types and asserts resolve + // through an unseeded adapter + $specifyTypesCallback = function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }; + $result = new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); if (!$expr->name instanceof Identifier) { @@ -237,8 +270,9 @@ public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, S isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } } @@ -246,6 +280,75 @@ public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, S return $result; } + /** + * New-world copy of resolveType(): the receiver comes from its + * ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md + * paragraph 3.10); args and dynamic-return extensions resolve through a + * self-seeded adapter so extension helpers re-asking about this call + * terminate (the FuncCall precedent); dynamic method names bridge. + */ +/** + * @param array $argResults + */ + private function resolveMethodCallTypeViaResults(MethodCall $e, MutatingScope $s, ExpressionResult $varResult, array $argResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt): Type + { + if (!$e->name instanceof Identifier) { + // dynamic method names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $varType = $varResult->getTypeForScope($s); + $isShortcircuited = ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varType); + if ($isShortcircuited) { + $varType = TypeCombinator::removeNull($varType); + } + + if ($s->nativeTypesPromoted) { + $methodReflection = $s->getMethodReflection($varType, $e->name->name); + if ($methodReflection === null) { + $returnType = new ErrorType(); + } else { + $returnType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + } + + return $isShortcircuited ? TypeCombinator::union($returnType, new NullType()) : $returnType; + } + + $storage = new ExpressionResultStorage(); + $selfResult = new ExpressionResult( + $s, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $e, + typeCallback: fn (Expr $innerExpr, MutatingScope $innerScope): Type => $this->resolveMethodCallTypeViaResults($e, $innerScope, $varResult, $argResults, $nodeScopeResolver, $stmt), + specifyTypesCallback: function (Expr $innerExpr, MutatingScope $innerScope, TypeSpecifierContext $innerContext) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$innerExpr instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + $innerAdapterScope = $innerScope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $innerAdapterScope, $innerExpr, $innerContext); + }, + ); + $adapterScope = $s->toResultAwareScope($argResults + [$s->getNodeKey($e) => $selfResult], $nodeScopeResolver, $stmt, $storage); + + $returnType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $adapterScope, + $varType, + $e->name->name, + $e, + ); + if ($returnType === null) { + $returnType = new ErrorType(); + } + + return $isShortcircuited ? TypeCombinator::union($returnType, new NullType()) : $returnType; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { From cc73ffcb844ca047ad3bb980ebde646139b853aa Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:06:16 +0200 Subject: [PATCH 45/50] Migrate StaticCallHandler to the new world The mirror of MethodCallHandler: late-static-binding name resolution stays scope-context-based, the class expression comes from its ExpressionResult (one-level nullsafe short-circuit), args and extensions resolve through the self-seeded adapter with per-arg companions, the old specifyTypes() body runs directly with an unseeded adapter, and the lazy return-type callback feeds the throw-point helper. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 12 +- .../ExprHandler/StaticCallHandler.php | 127 +++++++++++++++++- tests/PHPStan/Analyser/data/new-world.php | 30 +++++ 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 28aab21547..1d20c0ab71 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -301,17 +301,17 @@ as factual comments at their call sites, not here. - [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner - [x] EvalHandler — constant mixed typeCallback; default narrowing - [x] ExitHandler — constant NonAcceptingNeverType typeCallback; default narrowing -- [ ] FirstClassCallableFuncCallHandler -- [ ] FirstClassCallableMethodCallHandler -- [ ] FirstClassCallableNewHandler -- [ ] FirstClassCallableStaticCallHandler +- [x] FirstClassCallableFuncCallHandler — typed in the NSR fast-path callback (reflection-based; dynamic-name/receiver asks via unseeded adapters; always-truthy Closure narrowing) +- [x] FirstClassCallableMethodCallHandler — see FirstClassCallableFuncCallHandler +- [x] FirstClassCallableNewHandler — see FirstClassCallableFuncCallHandler +- [x] FirstClassCallableStaticCallHandler — see FirstClassCallableFuncCallHandler - [x] FuncCallHandler — dynamic-name calls bridge - [x] IncludeHandler — constant mixed typeCallback; default narrowing - [x] InstanceofHandler — typeCallback folds via the target/class results; specifyTypesCallback is the old create() math with an adapter seeded with the target and class results - [x] InterpolatedStringHandler — per-part results keyed by spl_object_id (each captured at its own evaluation point); concat folding via resolveConcatType; default narrowing - [x] IssetHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (BinaryOp precedent; the multi-isset And-chain synthetic routes through migrated handlers); ensureNonNullability asks priced via the askScopeFactory - [ ] MatchHandler -- [ ] MethodCallHandler +- [x] MethodCallHandler — typeCallback via resolveMethodCallTypeViaResults (receiver from its result, one-level nullsafe short-circuit, self-seeded adapter + per-arg companions from processArgs — passed closures keep their context memo); specifyTypesCallback = old body with unseeded adapter; lazy returnTypeCallback threaded into MethodThrowPointHelper - [ ] NewHandler - [x] NullsafeMethodCallHandler — shares the §3.10 callback; call part reused via MethodCallHandler::processCallWithVarResult; call type bridges until MethodCallHandler migrates; impure calls gate result narrowing - [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge @@ -323,7 +323,7 @@ as factual comments at their call sites, not here. - [x] PrintHandler — constant `1` typeCallback; default narrowing - [x] PropertyFetchHandler — one-level short-circuit propagation from a nullsafe var; dynamic names bridge - [x] ScalarHandler -- [ ] StaticCallHandler +- [x] StaticCallHandler — mirror of MethodCallHandler (late-static-binding name resolution; class expr from its result) - [x] StaticPropertyFetchHandler — typeCallback mirrors PropertyFetch (native via reflection finder; class-expr via the class result; one-level nullsafe short-circuit; dynamic names bridge); default narrowing - [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) - [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index e24683ac8f..2b6b068d10 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -36,11 +36,13 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\StaticType; @@ -66,6 +68,7 @@ public function __construct( private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, ) @@ -83,6 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -217,7 +221,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeFunction = $scope->getFunction(); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $throwPointScope = $scope; + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint( + $methodReflection, + $parametersAcceptor, + $normalizedExpr, + $scope, + $context, + fn (): Type => $this->resolveStaticCallTypeViaResults($normalizedExpr, $throwPointScope, $classResult, $argsResult->getCompanionResults(), $nodeScopeResolver, $stmt), + ); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -279,17 +291,126 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + $argResults = $argsResult->getCompanionResults(); + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($classResult, $argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveStaticCallTypeViaResults($e, $s, $classResult, $argResults, $nodeScopeResolver, $stmt); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — extensions, conditional return types and asserts + // resolve through an unseeded adapter + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } + /** + * New-world copy of resolveType(): the class expression comes from its + * ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md + * paragraph 3.10); args and dynamic-return extensions resolve through a + * self-seeded adapter (the FuncCall precedent); dynamic names bridge. + */ +/** + * @param array $argResults + */ + private function resolveStaticCallTypeViaResults(StaticCall $e, MutatingScope $s, ?ExpressionResult $classResult, array $argResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt): Type + { + if (!$e->name instanceof Identifier) { + // dynamic method names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $isShortcircuited = false; + $classType = null; + if ($e->class instanceof Expr) { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classType = $classResult->getTypeForScope($s); + $isShortcircuited = ($e->class instanceof Expr\NullsafePropertyFetch || $e->class instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($classType); + if ($isShortcircuited) { + $classType = TypeCombinator::removeNull($classType); + } + } + + if ($s->nativeTypesPromoted) { + if ($e->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($s, $e->class, $e->name); + } else { + $staticMethodCalledOnType = $classType; + } + $methodReflection = $s->getMethodReflection( + $staticMethodCalledOnType, + $e->name->name, + ); + if ($methodReflection === null) { + $callType = new ErrorType(); + } else { + $callType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + } + + return $isShortcircuited ? TypeCombinator::union($callType, new NullType()) : $callType; + } + + if ($e->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($s, $e->class, $e->name); + } else { + $staticMethodCalledOnType = $classType->getObjectTypeOrClassStringObjectType(); + } + + $storage = new ExpressionResultStorage(); + $selfResult = new ExpressionResult( + $s, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $e, + typeCallback: fn (Expr $innerExpr, MutatingScope $innerScope): Type => $this->resolveStaticCallTypeViaResults($e, $innerScope, $classResult, $argResults, $nodeScopeResolver, $stmt), + specifyTypesCallback: function (Expr $innerExpr, MutatingScope $innerScope, TypeSpecifierContext $innerContext) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$innerExpr instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + $innerAdapterScope = $innerScope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $innerAdapterScope, $innerExpr, $innerContext); + }, + ); + $adapterScope = $s->toResultAwareScope($argResults + [$s->getNodeKey($e) => $selfResult], $nodeScopeResolver, $stmt, $storage); + + $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $adapterScope, + $staticMethodCalledOnType, + $e->name->toString(), + $e, + ); + if ($callType === null) { + $callType = new ErrorType(); + } + + return $isShortcircuited ? TypeCombinator::union($callType, new NullType()) : $callType; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index b382956921..2511cb5a11 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -998,6 +998,36 @@ public function issetChecks(?int $i, array $map, string $k): void } } + public function firstClassCallables(Holder $h): void + { + assertType('Closure(string): int<0, max>', strlen(...)); + assertType('Closure(): int', $h->getCount(...)); + } + + public function methodAndStaticCalls(Holder $h, ?Holder $maybe): void + { + assertType('int', $h->getCount()); + assertType('int|null', $maybe?->getCount()); + if ($maybe?->getCount()) { + assertType(Holder::class, $maybe); + } + } + + /** + * A passed closure's parameter types come from the call context — the + * per-arg companion results carry the context-aware memo into the call + * handlers' adapters. + */ + public function passedClosureContext(): void + { + $mapped = array_map(static function ($s) { + assertType("'a'|'bb'", $s); + + return strlen($s); + }, ['a', 'bb']); + assertType('array{1, 2}', $mapped); + } + public function staticPropertyFetches(): void { assertType('int', StaticHolder::$count); From ba07b10a648ce9ee2556adfe0920cf506477b7a1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:20:32 +0200 Subject: [PATCH 46/50] Migrate NewHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typeCallback runs exactInstantiation (constructor template inference, parent-construct chains) on an adapter seeded with the per-arg companion results; anonymous classes resolve via reflection and dynamic class expressions through the adapter. The pre-args constructor selection prices its asks through adapters as well. The specifyTypesCallback invokes the old body with an unseeded adapter. Two memory lessons baked in: - adapters created per instantiation use FRESH storages — synthetic processing duplicates the adapter's storage, and duplicating the live per-file storage is O(file) (measured +38 MB peak over a 30-file directory, enough to OOM 599M workers); - processArgs retains only the closure/arrow-function context wrappers as companions, not every argument's full result. Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 2 +- src/Analyser/ExprHandler/NewHandler.php | 43 +++++++++++++++++++++-- src/Analyser/NodeScopeResolver.php | 11 +++--- tests/PHPStan/Analyser/data/new-world.php | 20 +++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 1d20c0ab71..e3330db7ea 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -312,7 +312,7 @@ as factual comments at their call sites, not here. - [x] IssetHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (BinaryOp precedent; the multi-isset And-chain synthetic routes through migrated handlers); ensureNonNullability asks priced via the askScopeFactory - [ ] MatchHandler - [x] MethodCallHandler — typeCallback via resolveMethodCallTypeViaResults (receiver from its result, one-level nullsafe short-circuit, self-seeded adapter + per-arg companions from processArgs — passed closures keep their context memo); specifyTypesCallback = old body with unseeded adapter; lazy returnTypeCallback threaded into MethodThrowPointHelper -- [ ] NewHandler +- [x] NewHandler — typeCallback via exactInstantiation on an adapter seeded with per-arg companions (constructor template inference; anonymous classes via reflection; dynamic class exprs); ctor selection pre-args priced through fresh-storage adapters (live-storage duplicate() is O(file) — the +38MB lesson); specifyTypesCallback = old body with unseeded adapter - [x] NullsafeMethodCallHandler — shares the §3.10 callback; call part reused via MethodCallHandler::processCallWithVarResult; call type bridges until MethodCallHandler migrates; impure calls gate result narrowing - [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge - [ ] PipeHandler diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 2360ccd807..d75c031b45 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -75,6 +75,7 @@ public function __construct( private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, private PropertyReflectionFinder $propertyReflectionFinder, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, ) @@ -100,7 +101,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, false); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()), false); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); if ($parametersAcceptor !== null) { @@ -162,7 +163,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } else { $isDynamic = true; - $objectClasses = $scope->getType($expr)->getObjectClassNames(); + // the class expression is not processed yet — price the ask through + // an adapter (the self-type of a dynamic New is derived from it) + $objectClasses = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType($expr->class)->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; @@ -181,7 +184,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, true); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()), true); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); } else { $impurePoints[] = new ImpurePoint( @@ -215,12 +218,46 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } + $argResults = $argsResult->getCompanionResults(); + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof New_) { + throw new ShouldNotHappenException(); + } + + // exactInstantiation resolves constructor template inference and + // parent-construct chains with scope asks — priced through an + // adapter seeded with the per-arg companions (passed closures + // keep their context memo) + $adapterScope = $s->toResultAwareScope($argResults, $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if ($e->class instanceof Name) { + return $this->exactInstantiation($adapterScope, $e, $e->class); + } + if ($e->class instanceof Node\Stmt\Class_) { + $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($e->class, $s); + + return new ObjectType($anonymousClassReflection->getName()); + } + + return $adapterScope->getType($e->class)->getObjectTypeOrClassStringObjectType(); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof New_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index da8bc35d69..38b80b9bdc 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3822,7 +3822,6 @@ public function processArgs( $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); $exprType = $exprResult->getType(); $argExprTypes[spl_object_id($arg->value)] = $exprType; - $argResults[$scope->getNodeKey($arg->value)] = $exprResult; $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -3955,10 +3954,12 @@ public function processArgs( } } - // not storing this, it's scope after processing all args; the per-arg - // results ride along so call handlers can seed their adapters — a passed - // closure's memoized type carries the parameter-type inference context - // that re-processing outside the call would lose + // not storing this, it's scope after processing all args; the closure/ + // arrow-function argument wrappers ride along so call handlers can seed + // their adapters — a passed closure's type resolves on its context scope + // (the parameter-type inference), which re-processing outside the call + // would lose. Plain arguments re-resolve through the adapter tiers and + // are NOT retained (memory). return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, companionResults: $argResults); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 2511cb5a11..90e041041f 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -998,6 +998,15 @@ public function issetChecks(?int $i, array $map, string $k): void } } + /** @param class-string $cls */ + public function instantiations(string $cls): void + { + assertType(Holder::class, new Holder()); + assertType(Holder::class, new $cls()); + assertType('NewWorldTypeInference\\GenericBox', new GenericBox(5)); + assertType('NewWorldTypeInference\\GenericBox', new GenericBox('s')); + } + public function firstClassCallables(Holder $h): void { assertType('Closure(string): int<0, max>', strlen(...)); @@ -1196,6 +1205,17 @@ public function isA(): bool; } +/** @template T */ +class GenericBox +{ + + /** @param T $value */ + public function __construct(public mixed $value) + { + } + +} + class StaticHolder { From aa13b6c9c221ecee40f928ef54ec27e9948fac6d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:24:28 +0200 Subject: [PATCH 47/50] Migrate YieldHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The yield expression evaluates to the enclosing generator's TSend — scope-context reads only; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/YieldHandler.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 48fb166ee7..53ea1b4914 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,10 @@ final class YieldHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Yield_; @@ -88,6 +93,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s): Type { + $functionReflection = $s->getFunction(); + if ($functionReflection === null) { + return new MixedType(); + } + + $returnType = $functionReflection->getReturnType(); + $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); + if ($generatorSendType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorSendType; + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From 7a634a0cd14e8e84e1ff60093704daf0b985ff6c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:24:28 +0200 Subject: [PATCH 48/50] Migrate YieldFromHandler to the new world yield from evaluates to the inner generator's TReturn, extracted from the inner expression's ExpressionResult; narrowing is the type-free default. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ExprHandler/YieldFromHandler.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 7b86f00abb..9ec51fed68 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,10 @@ final class YieldFromHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof YieldFrom; @@ -58,6 +63,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + $yieldFromType = $exprResult->getTypeForScope($s); + $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); + if ($generatorReturnType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorReturnType; + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } From cf41476beff4478b8b97eac597b0586a21f10ce5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:24:28 +0200 Subject: [PATCH 49/50] Migrate PipeHandler to the new world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipe operator is processed as its rewritten call — the result delegates type and narrowing to the call's ExpressionResult. The FuncCallHandler ask for invokable-object names is de-guarded along the way (the name result when available, an adapter otherwise). Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 6 +++--- src/Analyser/ExprHandler/FuncCallHandler.php | 4 +++- src/Analyser/ExprHandler/PipeHandler.php | 16 ++++++++++++++++ tests/PHPStan/Analyser/data/new-world.php | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index e3330db7ea..56e04a3eef 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -315,7 +315,7 @@ as factual comments at their call sites, not here. - [x] NewHandler — typeCallback via exactInstantiation on an adapter seeded with per-arg companions (constructor template inference; anonymous classes via reflection; dynamic class exprs); ctor selection pre-args priced through fresh-storage adapters (live-storage duplicate() is O(file) — the +38MB lesson); specifyTypesCallback = old body with unseeded adapter - [x] NullsafeMethodCallHandler — shares the §3.10 callback; call part reused via MethodCallHandler::processCallWithVarResult; call type bridges until MethodCallHandler migrates; impure calls gate result narrowing - [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge -- [ ] PipeHandler +- [x] PipeHandler — full delegation to the rewritten call's result (type + narrowing); the de-guarded FuncCall invokable-name ask rides along - [x] PostDecHandler - [x] PostIncHandler - [x] PreDecHandler @@ -330,8 +330,8 @@ as factual comments at their call sites, not here. - [x] UnaryMinusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [x] UnaryPlusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing - [x] VariableHandler — dynamic variable names bridge -- [ ] YieldFromHandler -- [ ] YieldHandler +- [x] YieldFromHandler — typeCallback extracts TReturn from the inner result; default narrowing +- [x] YieldHandler — typeCallback reads the enclosing generator's TSend (scope-context only); default narrowing ### Virtual node handlers diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 630485d3fe..29226ffcbb 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -318,7 +318,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($normalizedExpr->name instanceof Expr) { - $nameType = $scope->getType($normalizedExpr->name); + $nameType = $nameResult !== null && $normalizedExpr->name === $expr->name + ? $nameResult->getType() + : $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType($normalizedExpr->name); if ( $nameType->isObject()->yes() && $nameType->isCallable()->yes() diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 93bd3554ef..37d14be65d 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -32,6 +32,10 @@ final class PipeHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Pipe; @@ -90,6 +94,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), + expr: $expr, + // the pipe IS the rewritten call — full delegation to its result + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $callResult->getTypeForScope($s), + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($callResult, $callExpr, $nodeScopeResolver, $stmt): SpecifiedTypes { + if ($callResult->hasSpecifiedTypesCallback()) { + return $callResult->getSpecifiedTypes($s, $ctx)->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $callExpr, $ctx)->setRootExpr($e); + }, ); } diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php index 90e041041f..4eb5eb824f 100644 --- a/tests/PHPStan/Analyser/data/new-world.php +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -998,6 +998,25 @@ public function issetChecks(?int $i, array $map, string $k): void } } + /** @return \Generator */ + public function yields(): \Generator + { + $sent = yield 'value'; + assertType('bool', $sent); + $ret = yield from $this->innerGen(); + assertType('int', $ret); + + return 1.5; + } + + /** @return \Generator */ + private function innerGen(): \Generator + { + yield 'x'; + + return 5; + } + /** @param class-string $cls */ public function instantiations(string $cls): void { From f461883036699a15f71f12c9d038ef3ed756dff6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 11 Jun 2026 00:26:01 +0200 Subject: [PATCH 50/50] Document the 21-handler migration batch in NEW_WORLD.md Co-Authored-By: Claude Opus 4.8 --- NEW_WORLD.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/NEW_WORLD.md b/NEW_WORLD.md index 56e04a3eef..f239eaded9 100644 --- a/NEW_WORLD.md +++ b/NEW_WORLD.md @@ -619,6 +619,36 @@ as factual comments at their call sites, not here. parity (3 fresh findings fixed: redundant null check, unused truthy helper variant, Scope-vs-MutatingScope on the end-node adapter); DefinedVariableRuleTest testDynamicAccess failure confirmed pre-existing at HEAD. +- 2026-06-11 (handler batch): **21 more handlers migrated**, one commit each: + ConstFetch, UnaryMinus/UnaryPlus/BitwiseNot (§3.12), Print/Clone/Exit/Eval/Include + (constants/delegation), ErrorSuppress (full delegation), CastString/Cast (§3.12 + + comparison synthetics via adapters), ClassConstFetch, InterpolatedString (per-part + results), Instanceof, **BinaryOp** (the equality milestone: old body direct with + unseeded adapter; Type-taking RicherScopeGetTypeHelper variants kill the Identical + type bridge; operand companions; apply originals gain dim-fetch + TypeExpr + scalar + tiers; FiberScope::getKeepVoidType via the attributed-clone synthetic), ArrayDimFetch, + Isset/Empty (issetCheck on adapters; NonNullabilityHelper askScopeFactory), + **Coalesce** (isset synthetic through the migrated IssetHandler; the §3.13 + evaluation-base rule re-confirmed the hard way — never seed results evaluated on + ensured scopes), StaticPropertyFetch, the **first-class callable quartet** (typed in + the NSR fast-path), **MethodCall/StaticCall** (resolveViaResults with self-seeded + adapters + per-arg companions; the old specify bodies direct; MethodThrowPointHelper + takes the lazy return-type callback — the implicit-throws/never bridges are gone), + **New** (exactInstantiation on a companion-seeded adapter), Yield/YieldFrom, Pipe + (full delegation to the rewritten call). + Cross-cutting: `processArgs` carries closure/arrow context wrappers as + companionResults — a passed closure's type resolves on its context scope, fixing the + method/static flavor of the passed-closure problem (task #18's FuncCall flavor and its + 6 nsrt knowns remain). **Memory lesson (§3.11 extended): adapters in hot paths must + use fresh storages — synthetic processing duplicates the adapter's storage and + duplicating the live per-file storage is O(file)** (the New leg measured +38 MB peak + on a 30-file directory and OOM'd the 599M workers; with fresh storages the batch is + memory-neutral: 162.5 MB peak on src/Rules/Comparison before and after). + Scoreboard held at every commit: corpus grew 194 → 335 assertions; nsrt at the known + 6; `make phpstan` 204 = parity; FNSR=0 spot-identical. + Remaining handlers: AssignOp (before-scope machinery, AssignOp-Coalesce), Match + (per-arm Identical synthetics, enum fast-path — unlocks the AssignHandler Match + holder block), Closure + ArrowFunction (the task #18 design session). - **Known engine debt — `ExpressionResultStorage` memory retention**: every `ExpressionResult` (holding its after-scope, callbacks, memoized types) is retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker