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
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
new ArrayObject()is inferred asArrayObject<*NEVER*, *NEVER*>because its constructor defaults to an empty array. That is correct at the point of construction, but PHPStan kept theneverarguments even after the object was handed to code that could fill it: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
nevertype arguments to the bounds of the class'@templatetags at exactly those points, soArrayObject<*NEVER*, *NEVER*>becomesArrayObject<int|string, mixed>.Changes
src/Type/Generic/TemplateTypeHelper.php— newwidenNeverTypeArguments(Type): Type. It maps the type withTypeTraverserand, for everyGenericObjectType(and its siblingGenericStaticType), replaces eachnevertype argument with the bound of the matching declared template type. Composite types are handled by the traversal, soColl<never>|null,array{Coll<never>}and nested generics are covered.src/Analyser/NodeScopeResolver.phpwidenNeverTypeArguments(MutatingScope, Expr): MutatingScopethat 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$mayMutateflag so the widening also runs for non-object arguments — anarray<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 anArrayAccessobject ($arrayObject['k'] = 1,$arrayObject[] = 1). That path callsoffsetSet()and previously left the object's type completely untouched.Analogous constructs probed:
processArgs()/MethodCallHandlerand are fixed.assignExpression()on the argument/receiver expression.Coll<never>|null(union) and objects nested in array arguments are covered by theTypeTraversermapping.GenericObjectTypeandGenericStaticTypeare both handled.ArrayObject,ArrayIterator,SplObjectStorageand user-land collections all behave the same.@phpstan-purecallee keepsColl<*NEVER*>, and a fluent@phpstan-pure$this-returning method still does not widen, matching the existing invalidation condition.new Foo($coll)) — intentionally left unchanged.tests/PHPStan/Analyser/nsrt/impure-constructor.phpdocuments that an unmarked constructor is assumed not to mutate its arguments; only@phpstan-impureconstructors invalidate, and those now widen too. A test for both is included.$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-outis 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
neverarguments, which propagate intoarray{}/*NEVER*and triggerforeach.emptyArrayandfunction.alreadyNarrowedType. The fix pairs every existing "may have been mutated" site with a widening ofnevertype 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.phpcontains the reproducer from the issue's playground link verbatim, assertingArrayObject<int|string, mixed>afterpopulate()andarray<mixed>aftergetArrayCopy()(previouslyArrayObject<*NEVER*, *NEVER*>andarray{}).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,
ArrayAccessoffset writes with and without a key,ArrayIteratorandSplObjectStorage, plus negative cases pinning the unchanged behaviour for a@phpstan-purecallee and an unmarked constructor.Fixes phpstan/phpstan#15147