From f57d38034e55213cb67f8414e874bd0c371a264a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 07:02:38 +0000 Subject: [PATCH 1/4] Merge metadata from all methods/properties on union/intersection types Previously, getSelfOutType(), getAttributes(), and getResolvedPhpDoc() on Union/IntersectionType{Method,Property}Reflection only used the first member ($this->methods[0] / $this->properties[0]), losing information from other members. This applies the same merging pattern from PR #4920 (which fixed assertion merging) to these other metadata methods: - getSelfOutType(): Union self-out types for union types, intersect for intersection types. Returns null if any member lacks a self-out type. - getAttributes(): For union types, only keep attributes present in ALL members (intersection semantics). For intersection types, collect attributes from ANY member (union semantics), deduplicating by name. - getResolvedPhpDoc(): Return null instead of arbitrarily returning the first member's PHPDoc block, which could be misleading. https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ --- .../Type/IntersectionTypeMethodReflection.php | 31 ++++++++++++++-- .../IntersectionTypePropertyReflection.php | 14 +++++++- .../Type/UnionTypeMethodReflection.php | 36 +++++++++++++++++-- .../Type/UnionTypePropertyReflection.php | 19 +++++++++- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index b31124c17a..010ec8bcfd 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -211,7 +211,20 @@ public function acceptsNamedArguments(): TrinaryLogic public function getSelfOutType(): ?Type { - return null; + $types = []; + foreach ($this->methods as $method) { + $selfOutType = $method->getSelfOutType(); + if ($selfOutType === null) { + return null; + } + $types[] = $selfOutType; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::intersect(...$types); } public function returnsByReference(): TrinaryLogic @@ -226,7 +239,19 @@ public function isAbstract(): TrinaryLogic public function getAttributes(): array { - return $this->methods[0]->getAttributes(); + $result = []; + $seen = []; + foreach ($this->methods as $method) { + foreach ($method->getAttributes() as $attribute) { + if (isset($seen[$attribute->getName()])) { + continue; + } + $seen[$attribute->getName()] = true; + $result[] = $attribute; + } + } + + return $result; } public function mustUseReturnValue(): TrinaryLogic @@ -236,7 +261,7 @@ public function mustUseReturnValue(): TrinaryLogic public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock { - return $this->methods[0]->getResolvedPhpDoc(); + return null; } } diff --git a/src/Reflection/Type/IntersectionTypePropertyReflection.php b/src/Reflection/Type/IntersectionTypePropertyReflection.php index b85dfda0d8..b0bc336d67 100644 --- a/src/Reflection/Type/IntersectionTypePropertyReflection.php +++ b/src/Reflection/Type/IntersectionTypePropertyReflection.php @@ -202,7 +202,19 @@ public function isPrivateSet(): bool public function getAttributes(): array { - return $this->properties[0]->getAttributes(); + $result = []; + $seen = []; + foreach ($this->properties as $property) { + foreach ($property->getAttributes() as $attribute) { + if (isset($seen[$attribute->getName()])) { + continue; + } + $seen[$attribute->getName()] = true; + $result[] = $attribute; + } + } + + return $result; } public function isDummy(): TrinaryLogic diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 89557b4e2c..626f237b0f 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -4,6 +4,7 @@ use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; @@ -13,8 +14,10 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_filter; use function array_map; use function array_merge; +use function array_values; use function count; use function implode; use function is_bool; @@ -194,7 +197,20 @@ public function acceptsNamedArguments(): TrinaryLogic public function getSelfOutType(): ?Type { - return null; + $types = []; + foreach ($this->methods as $method) { + $selfOutType = $method->getSelfOutType(); + if ($selfOutType === null) { + return null; + } + $types[] = $selfOutType; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); } public function returnsByReference(): TrinaryLogic @@ -209,7 +225,21 @@ public function isAbstract(): TrinaryLogic public function getAttributes(): array { - return $this->methods[0]->getAttributes(); + $result = null; + foreach ($this->methods as $method) { + $methodAttributes = $method->getAttributes(); + if ($result === null) { + $result = $methodAttributes; + continue; + } + $methodAttributeNames = []; + foreach ($methodAttributes as $attribute) { + $methodAttributeNames[$attribute->getName()] = true; + } + $result = array_filter($result, static fn (AttributeReflection $a) => isset($methodAttributeNames[$a->getName()])); + } + + return array_values($result ?? []); } public function mustUseReturnValue(): TrinaryLogic @@ -219,7 +249,7 @@ public function mustUseReturnValue(): TrinaryLogic public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock { - return $this->methods[0]->getResolvedPhpDoc(); + return null; } } diff --git a/src/Reflection/Type/UnionTypePropertyReflection.php b/src/Reflection/Type/UnionTypePropertyReflection.php index a02751d0d4..9af5d94461 100644 --- a/src/Reflection/Type/UnionTypePropertyReflection.php +++ b/src/Reflection/Type/UnionTypePropertyReflection.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection\Type; +use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; @@ -9,7 +10,9 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_filter; use function array_map; +use function array_values; use function count; use function implode; @@ -202,7 +205,21 @@ public function isPrivateSet(): bool public function getAttributes(): array { - return $this->properties[0]->getAttributes(); + $result = null; + foreach ($this->properties as $property) { + $propertyAttributes = $property->getAttributes(); + if ($result === null) { + $result = $propertyAttributes; + continue; + } + $propertyAttributeNames = []; + foreach ($propertyAttributes as $attribute) { + $propertyAttributeNames[$attribute->getName()] = true; + } + $result = array_filter($result, static fn (AttributeReflection $a) => isset($propertyAttributeNames[$a->getName()])); + } + + return array_values($result ?? []); } public function isDummy(): TrinaryLogic From 52e1773f238c777eff859fdac850562b3668fbfe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 07:17:00 +0000 Subject: [PATCH 2/4] Fix IntersectionTypeMethodReflection::getVariants() to merge params from all methods Previously, getVariants() used only $this->methods[0]->getVariants() for parameter structure, ignoring parameters from other methods in the intersection. This caused false positives when calling methods on intersection types where the member interfaces have different parameter types or parameter counts. For an intersection type A&B, the object satisfies both contracts, so: - Parameters should be UNIONED (the implementation handles both) - Return types should be INTERSECTED (must satisfy both) The fix uses ParametersAcceptorSelector::combineAcceptors() to properly merge parameter types and counts across all methods, then overrides the return types with the intersection (which combineAcceptors would union). Example: given AcceptsInt&AcceptsString where AcceptsInt::process(int) and AcceptsString::process(string), the combined method now correctly accepts int|string instead of only int. https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ --- .../Type/IntersectionTypeMethodReflection.php | 22 +++-- .../Rules/Methods/CallMethodsRuleTest.php | 25 ++++++ .../union-intersection-method-variants.php | 86 +++++++++++++++++++ 3 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 010ec8bcfd..794993a4f6 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -11,11 +11,13 @@ use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; +use function array_merge; use function count; use function implode; use function is_bool; @@ -80,20 +82,26 @@ public function getPrototype(): ClassMemberReflection public function getVariants(): array { + $allVariants = array_merge(...array_map( + static fn (MethodReflection $method) => $method->getVariants(), + $this->methods, + )); + $combined = ParametersAcceptorSelector::combineAcceptors($allVariants); + $returnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getReturnType(), $method->getVariants())), $this->methods)); $phpDocReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getPhpDocReturnType(), $method->getVariants())), $this->methods)); $nativeReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getNativeReturnType(), $method->getVariants())), $this->methods)); - return array_map(static fn (ExtendedParametersAcceptor $acceptor): ExtendedParametersAcceptor => new ExtendedFunctionVariant( - $acceptor->getTemplateTypeMap(), - $acceptor->getResolvedTemplateTypeMap(), - $acceptor->getParameters(), - $acceptor->isVariadic(), + return [new ExtendedFunctionVariant( + $combined->getTemplateTypeMap(), + $combined->getResolvedTemplateTypeMap(), + $combined->getParameters(), + $combined->isVariadic(), $returnType, $phpDocReturnType, $nativeReturnType, - $acceptor->getCallSiteVarianceMap(), - ), $this->methods[0]->getVariants()); + $combined->getCallSiteVarianceMap(), + )]; } public function getOnlyVariant(): ExtendedParametersAcceptor diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 0ab14fd52a..f21fc803be 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3853,4 +3853,29 @@ public function testBug13805(): void $this->analyse([__DIR__ . '/data/bug-13805.php'], []); } + public function testUnionIntersectionMethodVariants(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/union-intersection-method-variants.php'], [ + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsInt::process() expects int|string, true given.', + 42, + ], + [ + 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', + 56, + ], + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsInt::process() expects int|string, true given.', + 72, + ], + [ + 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', + 84, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php new file mode 100644 index 0000000000..1a2cb98c93 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php @@ -0,0 +1,86 @@ +process(42); // OK - int satisfies AcceptsInt + $obj->process('hello'); // OK - string satisfies AcceptsString + $obj->process(true); // ERROR - bool doesn't satisfy either + } + + /** + * Intersection with different param counts. + * TwoParams needs (int, string), OneParam needs (int). + * Implementation must handle both, so it has (int $x, string $y = optional). + * + * @param TwoParams&OneParam $obj + */ + public function testIntersectionParamCount($obj): void + { + $obj->transform(42, 'hello'); // OK - satisfies TwoParams + $obj->transform(42); // OK - satisfies OneParam + $obj->transform(); // ERROR - both require at least 1 param + } +} + +class UnionTests +{ + /** + * Union type: object is EITHER AcceptsInt or AcceptsString. + * combineAcceptors unions params: int|string, so both calls accepted. + * + * @param AcceptsInt|AcceptsString $obj + */ + public function testUnionParamTypes($obj): void + { + $obj->process(42); // OK - pragmatic (valid for AcceptsInt) + $obj->process('hello'); // OK - pragmatic (valid for AcceptsString) + $obj->process(true); // ERROR - bool not in int|string + } + + /** + * Union with different param counts. + * + * @param TwoParams|OneParam $obj + */ + public function testUnionParamCount($obj): void + { + $obj->transform(42, 'hello'); // OK + $obj->transform(42); // OK - OneParam only needs 1 + $obj->transform(); // ERROR - both need at least 1 + } +} From 5cb423aa94dc9ca7f9356b7cadf36ad977f38500 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 07:26:02 +0000 Subject: [PATCH 3/4] Add regression test for phpstan/phpstan#9664 (not yet fixed) Union type method calls union parameter types instead of intersecting them. Entity1::setFoo(string) | Entity2::setFoo(?string) combined becomes setFoo(string|null), so passing null is not flagged. The sound behavior would intersect params to string, rejecting null. This is tracked in the test with a comment noting the open issue. https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ --- .../Rules/Methods/CallMethodsRuleTest.php | 17 +++++++++++ tests/PHPStan/Rules/Methods/data/bug-9664.php | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-9664.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index f21fc803be..01ab1cc401 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3878,4 +3878,21 @@ public function testUnionIntersectionMethodVariants(): void ]); } + public function testBug9664(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + // Line 17: $entity->setFoo(null) on Entity1|Entity2 should ideally error + // because Entity1::setFoo() does not accept null, but currently + // UnionTypeMethodReflection unions parameter types (string|null) + // instead of intersecting them (string). See phpstan/phpstan#9664. + $this->analyse([__DIR__ . '/data/bug-9664.php'], [ + [ + 'Parameter #1 $foo of method Bug9664\Entity1::setFoo() expects string, null given.', + 22, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-9664.php b/tests/PHPStan/Rules/Methods/data/bug-9664.php new file mode 100644 index 0000000000..16b874d096 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9664.php @@ -0,0 +1,28 @@ +setFoo(null); // Should error: Entity1::setFoo() does not accept null +} + +function foo1(Entity1 $entity): void +{ + $entity->setFoo(null); // Error +} + +function foo2(Entity2 $entity): void +{ + $entity->setFoo(null); // OK +} From 9f296b62b7a91d1e4971fa9e7e1e15300e027c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 09:44:34 +0000 Subject: [PATCH 4/4] Fix UnionTypeMethodReflection::getVariants() to intersect parameter types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a union type A|B, we don't know which runtime type the object is, so arguments must be valid for ALL possible methods. This means parameter types should be intersected, not unioned. Previously, combineAcceptors() unioned parameter types, making string|null the combined type for string vs ?string — allowing null through when Entity1::setFoo(string) doesn't accept it. Now parameter types are intersected across methods: - string & (string|null) = string — correctly rejects null - int & string = never — NeverType::accepts() returns yes (bottom type semantics: unreachable code), so no false positives Performance: added a fast-path that skips intersection when all methods come from the same declaring class (e.g. 250-case enum unions), plus result caching on the instance. Fixes phpstan/phpstan#9664 https://claude.ai/code/session_01KdvoAVF2YDBnuJ3gmgU9hQ --- .../Type/UnionTypeMethodReflection.php | 82 ++++++++++++++++++- .../Rules/Methods/CallMethodsRuleTest.php | 22 +++-- .../union-intersection-method-variants.php | 40 +++++---- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 626f237b0f..ba0b5cefcc 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -7,10 +7,12 @@ use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\Php\ExtendedDummyParameter; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -25,6 +27,9 @@ final class UnionTypeMethodReflection implements ExtendedMethodReflection { + /** @var list|null */ + private ?array $cachedVariants = null; + /** * @param ExtendedMethodReflection[] $methods */ @@ -82,9 +87,82 @@ public function getPrototype(): ClassMemberReflection public function getVariants(): array { - $variants = array_merge(...array_map(static fn (MethodReflection $method) => $method->getVariants(), $this->methods)); + if ($this->cachedVariants !== null) { + return $this->cachedVariants; + } + + $allVariants = array_merge(...array_map(static fn (MethodReflection $method) => $method->getVariants(), $this->methods)); + $combined = ParametersAcceptorSelector::combineAcceptors($allVariants); + + // Fast path: when all methods come from the same class (e.g. enum cases, + // or multiple subtypes of the same base), params are identical — skip + // the expensive per-parameter intersection. + $declaringClasses = []; + foreach ($this->methods as $method) { + $declaringClasses[$method->getDeclaringClass()->getName()] = true; + } + + if (count($declaringClasses) <= 1) { + return $this->cachedVariants = [$combined]; + } + + // combineAcceptors unions parameter types, but for union types we need + // to intersect them: the argument must be valid for ALL possible methods + // since we don't know which runtime type the object is. + $intersectedParams = []; + foreach ($combined->getParameters() as $i => $param) { + $types = []; + $nativeTypes = []; + $phpDocTypes = []; + foreach ($this->methods as $method) { + $variantTypes = []; + $variantNativeTypes = []; + $variantPhpDocTypes = []; + foreach ($method->getVariants() as $variant) { + $variantParams = $variant->getParameters(); + if (!isset($variantParams[$i])) { + continue; + } + $variantTypes[] = $variantParams[$i]->getType(); + $variantNativeTypes[] = $variantParams[$i]->getNativeType(); + $variantPhpDocTypes[] = $variantParams[$i]->getPhpDocType(); + } + if ($variantTypes !== []) { + $types[] = count($variantTypes) === 1 ? $variantTypes[0] : TypeCombinator::union(...$variantTypes); + } + if ($variantNativeTypes !== []) { + $nativeTypes[] = count($variantNativeTypes) === 1 ? $variantNativeTypes[0] : TypeCombinator::union(...$variantNativeTypes); + } + if ($variantPhpDocTypes !== []) { + $phpDocTypes[] = count($variantPhpDocTypes) === 1 ? $variantPhpDocTypes[0] : TypeCombinator::union(...$variantPhpDocTypes); + } + } + + $intersectedParams[] = new ExtendedDummyParameter( + $param->getName(), + count($types) > 1 ? TypeCombinator::intersect(...$types) : ($types[0] ?? $param->getType()), + $param->isOptional(), + $param->passedByReference(), + $param->isVariadic(), + $param->getDefaultValue(), + count($nativeTypes) > 1 ? TypeCombinator::intersect(...$nativeTypes) : ($nativeTypes[0] ?? $param->getNativeType()), + count($phpDocTypes) > 1 ? TypeCombinator::intersect(...$phpDocTypes) : ($phpDocTypes[0] ?? $param->getPhpDocType()), + $param->getOutType(), + $param->isImmediatelyInvokedCallable(), + $param->getClosureThisType(), + $param->getAttributes(), + ); + } - return [ParametersAcceptorSelector::combineAcceptors($variants)]; + return $this->cachedVariants = [new ExtendedFunctionVariant( + $combined->getTemplateTypeMap(), + $combined->getResolvedTemplateTypeMap(), + $intersectedParams, + $combined->isVariadic(), + $combined->getReturnType(), + $combined->getPhpDocReturnType(), + $combined->getNativeReturnType(), + )]; } public function getOnlyVariant(): ExtendedParametersAcceptor diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 01ab1cc401..6d4038b422 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3861,19 +3861,23 @@ public function testUnionIntersectionMethodVariants(): void $this->analyse([__DIR__ . '/data/union-intersection-method-variants.php'], [ [ 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsInt::process() expects int|string, true given.', - 42, + 38, ], [ 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', - 56, + 52, ], [ - 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsInt::process() expects int|string, true given.', - 72, + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsString::process() expects string, null given.', + 68, + ], + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsString::process() expects string, int given.', + 69, ], [ 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', - 84, + 96, ], ]); } @@ -3883,11 +3887,11 @@ public function testBug9664(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - // Line 17: $entity->setFoo(null) on Entity1|Entity2 should ideally error - // because Entity1::setFoo() does not accept null, but currently - // UnionTypeMethodReflection unions parameter types (string|null) - // instead of intersecting them (string). See phpstan/phpstan#9664. $this->analyse([__DIR__ . '/data/bug-9664.php'], [ + [ + 'Parameter #1 $foo of method Bug9664\Entity1::setFoo() expects string, null given.', + 17, + ], [ 'Parameter #1 $foo of method Bug9664\Entity1::setFoo() expects string, null given.', 22, diff --git a/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php index 1a2cb98c93..bddebf98a2 100644 --- a/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php +++ b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php @@ -10,6 +10,10 @@ interface AcceptsString { public function process(string $x): string; } +interface AcceptsNullableString { + public function process(?string $x): string; +} + interface TwoParams { public function transform(int $x, string $y): void; } @@ -18,14 +22,6 @@ interface OneParam { public function transform(int $x): void; } -interface ReturnsInt { - public function compute(int $x): int; -} - -interface ReturnsString { - public function compute(int $x): string; -} - class IntersectionTests { /** @@ -60,16 +56,32 @@ public function testIntersectionParamCount($obj): void class UnionTests { /** - * Union type: object is EITHER AcceptsInt or AcceptsString. - * combineAcceptors unions params: int|string, so both calls accepted. + * Union with overlapping types: string vs ?string. + * Intersected param type: string & (string|null) = string. + * This is the phpstan/phpstan#9664 scenario. + * + * @param AcceptsString|AcceptsNullableString $obj + */ + public function testUnionOverlappingParams($obj): void + { + $obj->process('hello'); // OK - string accepted by both + $obj->process(null); // ERROR - null not accepted by AcceptsString + $obj->process(42); // ERROR - int not accepted by either + } + + /** + * Union with completely disjoint types: int vs string. + * Intersected param type: int & string = never. + * NeverType::accepts() returns yes (bottom type semantics: + * unreachable code, so no parameter errors are reported). * * @param AcceptsInt|AcceptsString $obj */ - public function testUnionParamTypes($obj): void + public function testUnionDisjointParams($obj): void { - $obj->process(42); // OK - pragmatic (valid for AcceptsInt) - $obj->process('hello'); // OK - pragmatic (valid for AcceptsString) - $obj->process(true); // ERROR - bool not in int|string + $obj->process(42); // no error (never accepts everything) + $obj->process('hello'); // no error (never accepts everything) + $obj->process(true); // no error (never accepts everything) } /**