Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Widen never generic type arguments to their template bounds after a…
…n object might have been mutated

- Added `TemplateTypeHelper::widenNeverTypeArguments()`, which maps a type and replaces `never` type arguments of `GenericObjectType`/`GenericStaticType` with the bounds of the corresponding `@template` tags.
- Added `NodeScopeResolver::widenNeverTypeArguments()`, which re-assigns the widened PHPDoc and native types of an expression in the scope.
- Called it wherever PHPStan already assumes an object may have been mutated:
  - `NodeScopeResolver::processArgs()` - arguments of a call with side effects (functions, methods, static methods); also applies to objects nested inside an array argument, which the existing invalidation does not cover.
  - `MethodCallHandler` - the receiver of a method with side effects and of an unresolved method call.
  - `AssignHandler` - writing an offset on an `ArrayAccess` object (`$o['k'] = ...`, `$o[] = ...`), which goes through `offsetSet()` and previously left the receiver's type untouched.
- Probed and left unchanged: constructor arguments (`new Foo($coll)`), where PHPStan deliberately assumes an unmarked constructor does not mutate its arguments (see `nsrt/impure-constructor.php`); an `@phpstan-impure` constructor does widen. Nullsafe method calls (`$o?->add()`) still lose the widening because `NonNullabilityHelper::revertNonNullability()` restores the receiver's entry type wholesale - the same pre-existing limitation already drops `@phpstan-self-out` there.
  • Loading branch information
phpstan-bot committed Sep 1, 2026
commit 085abe37af120ab0b5141b4ddeb19be459a09d82
3 changes: 3 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,9 @@ public function applyWrite(
$scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString());
}
}

// offsetSet() filled the ArrayAccess object, so its `never` generics no longer hold
$scope = $nodeScopeResolver->widenNeverTypeArguments($scope, $var);
}

foreach ($additionalExpressions as $k => $additionalExpression) {
Expand Down
2 changes: 2 additions & 0 deletions src/Analyser/ExprHandler/MethodCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) {
$nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage);
$scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass());
$scope = $nodeScopeResolver->widenNeverTypeArguments($scope, $normalizedExpr->var);
} elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) {
// the remembered call value and the @phpstan-self-out type are
// generic-sensitive: resolve them from the type-driven acceptor
Expand Down Expand Up @@ -208,6 +209,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
} else {
$nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage);
$scope = $scope->invalidateExpression($normalizedExpr->var, true);
$scope = $nodeScopeResolver->widenNeverTypeArguments($scope, $normalizedExpr->var);
$throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr);
}
if (
Expand Down
32 changes: 28 additions & 4 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
use PHPStan\Type\FunctionParameterClosureThisExtension;
use PHPStan\Type\FunctionParameterClosureTypeExtension;
use PHPStan\Type\FunctionParameterOutTypeExtension;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\MethodParameterClosureThisExtension;
use PHPStan\Type\MethodParameterClosureTypeExtension;
use PHPStan\Type\MethodParameterOutTypeExtension;
Expand Down Expand Up @@ -1443,6 +1444,24 @@
return $closureScope->withAnonymousFunctionReflection($refinedClosureType);
}

/**
* An object created empty - like `new ArrayObject()` - has `never` generic type
* arguments. After it might have been mutated they no longer describe its contents,
* so they're widened to the bounds of the class' template types.
*/
public function widenNeverTypeArguments(MutatingScope $scope, Expr $expr): MutatingScope
{
$type = $scope->getType($expr);
$widenedType = TemplateTypeHelper::widenNeverTypeArguments($type);
if ($widenedType === $type) {
return $scope;
}

$nativeType = $scope->getNativeType($expr);

return $scope->assignExpression($expr, $widenedType, TemplateTypeHelper::widenNeverTypeArguments($nativeType));
}

/**
* @param InvalidateExprNode[] $invalidatedExpressions
* @param string[] $uses
Expand Down Expand Up @@ -2307,6 +2326,7 @@
}
} elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) {
$argType = $scope->getType($arg->value);
$mayMutate = true;
if (!$argType->isObject()->no()) {
$nakedReturnType = null;
if ($nakedMethodReflection !== null) {
Expand All @@ -2319,18 +2339,22 @@
);
$nakedReturnType = $nakedParametersAcceptor->getReturnType();
}
if (
$nakedReturnType === null
$mayMutate = $nakedReturnType === null
|| !(new ThisType($nakedMethodReflection->getDeclaringClass()))->isSuperTypeOf($nakedReturnType)->yes()
|| $nakedMethodReflection->isPure()->no()
) {
|| $nakedMethodReflection->isPure()->no();

Check warning on line 2344 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $mayMutate = $nakedReturnType === null || !(new ThisType($nakedMethodReflection->getDeclaringClass()))->isSuperTypeOf($nakedReturnType)->yes() - || $nakedMethodReflection->isPure()->no(); + || !$nakedMethodReflection->isPure()->yes(); if ($mayMutate) { $this->callNodeCallback($nodeCallback, new InvalidateExprNode($arg->value), $scope, $storage); $scope = $scope->invalidateExpression($arg->value, true);

Check warning on line 2344 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $mayMutate = $nakedReturnType === null || !(new ThisType($nakedMethodReflection->getDeclaringClass()))->isSuperTypeOf($nakedReturnType)->yes() - || $nakedMethodReflection->isPure()->no(); + || !$nakedMethodReflection->isPure()->yes(); if ($mayMutate) { $this->callNodeCallback($nodeCallback, new InvalidateExprNode($arg->value), $scope, $storage); $scope = $scope->invalidateExpression($arg->value, true);
if ($mayMutate) {
$this->callNodeCallback($nodeCallback, new InvalidateExprNode($arg->value), $scope, $storage);
$scope = $scope->invalidateExpression($arg->value, true);
}
} elseif (!(new ResourceType())->isSuperTypeOf($argType)->no()) {
$this->callNodeCallback($nodeCallback, new InvalidateExprNode($arg->value), $scope, $storage);
$scope = $scope->invalidateExpression($arg->value, true);
}

if ($mayMutate) {
// objects reachable from the argument might have been filled in by the callee
$scope = $this->widenNeverTypeArguments($scope, $arg->value);
}
}
}
}
Expand Down
71 changes: 71 additions & 0 deletions src/Type/Generic/TemplateTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace PHPStan\Type\Generic;

use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Type\ErrorType;
use PHPStan\Type\GeneralizePrecision;
use PHPStan\Type\NeverType;
use PHPStan\Type\NonAcceptingNeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\VerbosityLevel;
use function array_values;

final class TemplateTypeHelper
{
Expand Down Expand Up @@ -94,6 +97,74 @@ public static function resolveToBounds(Type $type): Type
});
}

/**
* Widens `never` type arguments of generic objects to their template bounds.
*
* An object constructed empty - like `new ArrayObject()` - gets `never` type
* arguments inferred. Once the object might have been mutated, `never` no longer
* describes what it can contain, but a wider type inferred from the call site
* would be unsound, so the bounds are the safest thing to fall back to.
*/
public static function widenNeverTypeArguments(Type $type): Type
{
return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type {
if ($type instanceof GenericObjectType) {
$widenedTypes = self::widenNeverTypesToBounds($type->getTypes(), $type->getClassReflection());
if ($widenedTypes !== null) {
return $traverse(new GenericObjectType(
$type->getClassName(),
$widenedTypes,
$type->getSubtractedType(),
variances: $type->getVariances(),
));
}
} elseif ($type instanceof GenericStaticType) {
$widenedTypes = self::widenNeverTypesToBounds($type->getTypes(), $type->getClassReflection());
if ($widenedTypes !== null) {
return $traverse(new GenericStaticType(
$type->getClassReflection(),
$widenedTypes,
$type->getSubtractedType(),
$type->getVariances(),
));
}
}

return $traverse($type);
});
}

/**
* @param array<int, Type> $typeArguments
* @return array<int, Type>|null null when nothing was widened
*/
private static function widenNeverTypesToBounds(array $typeArguments, ?ClassReflection $classReflection): ?array
{
if ($classReflection === null) {
return null;
}

$templateTypes = array_values($classReflection->getTemplateTypeMap()->getTypes());
$widened = false;
foreach ($typeArguments as $i => $typeArgument) {
if (!$typeArgument instanceof NeverType) {
continue;
}
if (!isset($templateTypes[$i])) {
continue;
}
$templateType = $templateTypes[$i];
if (!$templateType instanceof TemplateType) {
continue;
}

$typeArguments[$i] = $templateType->getBound();
$widened = true;
}

return $widened ? $typeArguments : null;
}

/**
* @template T of Type
* @param T $type
Expand Down
Loading
Loading