Skip to content

Widen never generic type arguments to their template bounds after an object might have been mutated - #6326

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-yqq0veg
Open

Widen never generic type arguments to their template bounds after an object might have been mutated#6326
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-yqq0veg

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

new ArrayObject() is inferred as ArrayObject<*NEVER*, *NEVER*> because its constructor defaults to an empty array. That is correct at the point of construction, but PHPStan kept the never arguments even after the object was handed to code that could fill it:

$alias = new ArrayObject();
$this->populate($alias);        // ArrayObject::append() called inside
$alias = $alias->getArrayCopy(); // array{}

foreach ($alias as $k => $v) {   // Empty array passed to foreach.
    if (!is_array($v)) {         // ... with *NEVER* will always evaluate to true.
        $alias[$k] = [];
    }
}

PHPStan already invalidates remembered method-call results on an object once it might have been mutated; it just never touched the generics. This PR widens the never type arguments to the bounds of the class' @template tags at exactly those points, so ArrayObject<*NEVER*, *NEVER*> becomes ArrayObject<int|string, mixed>.

Changes

  • src/Type/Generic/TemplateTypeHelper.php — new widenNeverTypeArguments(Type): Type. It maps the type with TypeTraverser and, for every GenericObjectType (and its sibling GenericStaticType), replaces each never type argument with the bound of the matching declared template type. Composite types are handled by the traversal, so Coll<never>|null, array{Coll<never>} and nested generics are covered.
  • src/Analyser/NodeScopeResolver.php
    • new widenNeverTypeArguments(MutatingScope, Expr): MutatingScope that re-assigns the widened PHPDoc and native type of an expression (a no-op when nothing was widened);
    • processArgs() now calls it for every argument of a call that has side effects. The invalidation branch was refactored into a $mayMutate flag so the widening also runs for non-object arguments — an array<Coll<never>> argument holds objects the callee can fill in.
  • src/Analyser/ExprHandler/MethodCallHandler.php — widens the receiver after a method with side effects ($coll->add(...), $arrayObject->append(...)) and after a call to an unresolved method.
  • src/Analyser/ExprHandler/AssignHandler.php — widens the receiver when an offset is written on an ArrayAccess object ($arrayObject['k'] = 1, $arrayObject[] = 1). That path calls offsetSet() and previously left the object's type completely untouched.

Analogous constructs probed:

  • Callable kinds — plain functions, methods, static methods and unresolved method calls all go through processArgs() / MethodCallHandler and are fixed.
  • Assignment targets — local variables, instance properties and static properties all widen, since the widening is expressed as an assignExpression() on the argument/receiver expression.
  • Composite typesColl<never>|null (union) and objects nested in array arguments are covered by the TypeTraverser mapping.
  • Generic type classesGenericObjectType and GenericStaticType are both handled.
  • Other empty-generic classesArrayObject, ArrayIterator, SplObjectStorage and user-land collections all behave the same.
  • Purity — a @phpstan-pure callee keeps Coll<*NEVER*>, and a fluent @phpstan-pure $this-returning method still does not widen, matching the existing invalidation condition.
  • Constructor arguments (new Foo($coll)) — intentionally left unchanged. tests/PHPStan/Analyser/nsrt/impure-constructor.php documents that an unmarked constructor is assumed not to mutate its arguments; only @phpstan-impure constructors invalidate, and those now widen too. A test for both is included.
  • Nullsafe method calls ($coll?->add(1)) — probed, still not widened. NonNullabilityHelper::revertNonNullability() restores the receiver's entry-scope type wholesale after the desugared method call, so any receiver type update is dropped there; @phpstan-self-out is lost through ?-> for exactly the same reason. That is a pre-existing, orthogonal limitation and is out of scope here.

Root cause

Two representations of "this object may have changed" were out of sync. When a call can mutate an object, PHPStan calls Scope::invalidateExpression($expr, requireMoreCharacters: true), which forgets remembered method-call results derived from the expression but deliberately keeps the expression's own type. For a normal object that is right — the class does not change. For a generic object it is wrong: the type arguments are a statement about the object's contents, and the contents are precisely what a mutation changes.

The visible symptom is worst when the object was constructed empty, because inference then produces never arguments, which propagate into array{} / *NEVER* and trigger foreach.emptyArray and function.alreadyNarrowedType. The fix pairs every existing "may have been mutated" site with a widening of never type arguments to the template bounds — the widest sound assumption, as opposed to guessing a narrower type from the call site.

Test

tests/PHPStan/Analyser/nsrt/bug-15147.php contains the reproducer from the issue's playground link verbatim, asserting ArrayObject<int|string, mixed> after populate() and array<mixed> after getArrayCopy() (previously ArrayObject<*NEVER*, *NEVER*> and array{}).

It additionally covers the analogous cases: a method with side effects called on the object, a function / static method / impure constructor / array-wrapped argument, an instance property and a static property receiver, a nullable union receiver, ArrayAccess offset writes with and without a key, ArrayIterator and SplObjectStorage, plus negative cases pinning the unchanged behaviour for a @phpstan-pure callee and an unmarked constructor.

Fixes phpstan/phpstan#15147

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive about empty array with foreach loop

1 participant