From d856002b982fe591e4cc6a59e69d89f213ebcbd6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 12:42:55 +0000 Subject: [PATCH] Skip raw generic `ObjectType` method/property prototype when `GenericObjectType` ancestor is present in `IntersectionType` - When an IntersectionType contains both a raw ObjectType for a generic class (e.g. Datagrid) and a GenericObjectType of its ancestor (e.g. DatagridInterface), skip the raw type's method/property prototype contribution if the ancestor also declares the member - The raw ObjectType resolves templates to their bounds (e.g. ProxyQueryInterface), while the GenericObjectType ancestor resolves them to the specific type args (e.g. Proxy). Intersecting these two invariant generic return types produces `never`, which is incorrect - Applied the same fix to instance property, static property, and method prototype resolution in IntersectionType - Methods unique to the raw type (not declared on the ancestor) are still resolved normally Closes https://github.com/phpstan/phpstan/issues/5357 --- phpstan-baseline.neon | 12 ++ src/Type/IntersectionType.php | 69 +++++++++ tests/PHPStan/Analyser/nsrt/bug-5357.php | 172 +++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-5357.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f6cd6fd593..997cbddd6a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1437,12 +1437,24 @@ parameters: count: 1 path: src/Type/IntersectionType.php + - + rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. + identifier: phpstanApi.instanceofType + count: 5 + path: src/Type/IntersectionType.php + - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType count: 3 path: src/Type/IntersectionType.php + - + rawMessage: 'Doing instanceof PHPStan\Type\ObjectType is error-prone and deprecated. Use Type::isObject() or Type::getObjectClassNames() instead.' + identifier: phpstanApi.instanceofType + count: 3 + path: src/Type/IntersectionType.php + - rawMessage: 'Method PHPStan\Type\IntersectionType::getConstantArrays() should return list but returns array{PHPStan\Type\Type}.' identifier: return.type diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index de09752cac..e636f94d49 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -43,6 +43,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Enum\EnumCaseObjectType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateArrayType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; @@ -669,6 +670,10 @@ public function getUnresolvedInstancePropertyPrototype(string $propertyName, Cla continue; } + if ($type instanceof ObjectType && !$type instanceof GenericObjectType && $this->hasGenericAncestorWithPropertyInIntersection($type, $propertyName, false)) { + continue; + } + $propertyPrototypes[] = $type->getUnresolvedInstancePropertyPrototype($propertyName, $scope)->withFechedOnType($this); } @@ -702,6 +707,10 @@ public function getUnresolvedStaticPropertyPrototype(string $propertyName, Class continue; } + if ($type instanceof ObjectType && !$type instanceof GenericObjectType && $this->hasGenericAncestorWithPropertyInIntersection($type, $propertyName, true)) { + continue; + } + $propertyPrototypes[] = $type->getUnresolvedStaticPropertyPrototype($propertyName, $scope)->withFechedOnType($this); } @@ -740,6 +749,10 @@ public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAcce continue; } + if ($type instanceof ObjectType && !$type instanceof GenericObjectType && $this->hasGenericAncestorWithMethodInIntersection($type, $methodName)) { + continue; + } + $methodPrototypes[] = $type->getUnresolvedMethodPrototype($methodName, $scope)->withCalledOnType($this); } @@ -755,6 +768,62 @@ public function getUnresolvedMethodPrototype(string $methodName, ClassMemberAcce return new IntersectionTypeUnresolvedMethodPrototypeReflection($methodName, $methodPrototypes); } + private function hasGenericAncestorWithMethodInIntersection(ObjectType $type, string $methodName): bool + { + $nakedReflection = $type->getNakedClassReflection(); + if ($nakedReflection === null || !$nakedReflection->isGeneric()) { + return false; + } + + foreach ($this->types as $otherType) { + if ($otherType === $type) { + continue; + } + if (!$otherType instanceof GenericObjectType) { + continue; + } + if ($type->getAncestorWithClassName($otherType->getClassName()) === null) { + continue; + } + if ($otherType->hasMethod($methodName)->yes()) { + return true; + } + } + + return false; + } + + private function hasGenericAncestorWithPropertyInIntersection(ObjectType $type, string $propertyName, bool $static): bool + { + $nakedReflection = $type->getNakedClassReflection(); + if ($nakedReflection === null || !$nakedReflection->isGeneric()) { + return false; + } + + foreach ($this->types as $otherType) { + if ($otherType === $type) { + continue; + } + if (!$otherType instanceof GenericObjectType) { + continue; + } + if ($type->getAncestorWithClassName($otherType->getClassName()) === null) { + continue; + } + if ($static) { + if ($otherType->hasStaticProperty($propertyName)->yes()) { + return true; + } + } else { + if ($otherType->hasInstanceProperty($propertyName)->yes()) { + return true; + } + } + } + + return false; + } + public function canAccessConstants(): TrinaryLogic { return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->canAccessConstants()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-5357.php b/tests/PHPStan/Analyser/nsrt/bug-5357.php new file mode 100644 index 0000000000..28409396bd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-5357.php @@ -0,0 +1,172 @@ + + */ +class Pager implements PagerInterface +{} + +/** + * @phpstan-template T of ProxyQueryInterface + */ +interface DatagridInterface +{ + /** + * @phpstan-return PagerInterface + */ + public function getPager(): PagerInterface; +} + +/** + * @phpstan-template T of ProxyQueryInterface + * @phpstan-implements DatagridInterface + */ +class Datagrid implements DatagridInterface +{ + /** + * @phpstan-return PagerInterface + */ + public function getPager(): PagerInterface + { + throw new \Exception(); + } + + /** + * Method unique to Datagrid (not on DatagridInterface) + * @phpstan-return T + */ + public function getQuery(): ProxyQueryInterface + { + throw new \Exception(); + } +} + +interface ProxyQueryInterface {} + +class Proxy implements ProxyQueryInterface {} + +class MockObject {} + +/** + * @phpstan-template T of ProxyQueryInterface + * @phpstan-extends Datagrid + */ +class ChildDatagrid extends Datagrid +{ +} + +/** + * @phpstan-template T of ProxyQueryInterface + */ +interface DatagridBuilderInterface +{ + /** + * @param AdminInterface $admin + * @param array $values + * + * @phpstan-return DatagridInterface + */ + public function getBaseDatagrid(AdminInterface $admin, array $values = []): DatagridInterface; +} + +/** + * @phpstan-implements DatagridBuilderInterface + */ +class DatagridBuilder implements DatagridBuilderInterface +{ + /** + * @param AdminInterface $admin + * @param array $values + * @phpstan-return DatagridInterface + */ + public function getBaseDatagrid(AdminInterface $admin, array $values = []): DatagridInterface + { + throw new \Exception(); + } +} + +class HelloWorld +{ + /** + * @var MockObject&AdminInterface + */ + public $admin; + + public DatagridBuilder $datagridBuilder; + + public function sayHello(): void + { + $datagrid = $this->datagridBuilder->getBaseDatagrid($this->admin); + assert($datagrid instanceof Datagrid); + assertType('Bug5357\Datagrid&Bug5357\DatagridInterface', $datagrid); + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * @param Datagrid&DatagridInterface $datagrid + */ + public function testDirect($datagrid): void + { + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * @param Datagrid $datagrid + */ + public function testGeneric($datagrid): void + { + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * Test with parent class generic intersection (not interface) + * @param ChildDatagrid&Datagrid $datagrid + */ + public function testWithGenericParentClass($datagrid): void + { + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * Test with multiple generic levels in hierarchy + * @param ChildDatagrid&DatagridInterface $datagrid + */ + public function testWithGrandparentInterface($datagrid): void + { + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * When both types already have explicit generics, existing intersection behavior should be preserved + * @param Datagrid&DatagridInterface $datagrid + */ + public function testBothGeneric($datagrid): void + { + assertType('Bug5357\PagerInterface', $datagrid->getPager()); + } + + /** + * Method unique to Datagrid should still resolve to bounds when raw type is skipped + * (it won't be skipped because DatagridInterface doesn't have getQuery) + * @param Datagrid&DatagridInterface $datagrid + */ + public function testUniqueMethod($datagrid): void + { + assertType('Bug5357\ProxyQueryInterface', $datagrid->getQuery()); + } +}