Fix sealed class-string match exhaustiveness for ::class comparisons#5305
Conversation
|
You've opened the pull request against the latest branch 2.2.x. PHPStan 2.2 is not going to be released for months. If your code is relevant on 2.1.x and you want it to be released sooner, please rebase your pull request and change its target to 2.1.x. |
e046593 to
3b9ff34
Compare
3b9ff34 to
708c2c2
Compare
|
This pull request has been marked as ready for review. |
708c2c2 to
23b1556
Compare
c3bfa1d to
ab7cbdd
Compare
|
this looks like a very big AI generated PR. @mhert did review it yourself and double checked whether it makes sense to you? |
I'm not an expert in phpstan's internals, but I did my best, to try to understand what's happening, and I think the AI did a good job. I also fixed one possible performance bottleneck (unnecessary foreach in the generated code) by myself. I also did let the AI do some reviews and let the upgraded phpstan run against some repositories I work with. I didn't notice any problems in correctness or performance. So I'm sure that this PR is okay. Thanks for looking in 😊 |
|
I can tell it’s wrong just be looking at the diff size, sorry 😊 |
As there are two fixes, should I create two pull requests, to make it easier to review (the commits are separated by topic already)? And if the diff size is too big, should I add less tests? It's "only" +130 without tests. And if you think the implementation is wrong, could you please give me a hint of what to do instead, to resolve phpstan/phpstan#12241 and the narrowing of a generic union with ::class. I would love to get this implemented and I will do my very best, to make it possible! Edit: @ondrejmirtes Looks like your comment made Claude to overthink the implmentation. Let me update my PR. Thanks for the hint! |
ab7cbdd to
7d24fb7
Compare
|
This is all the needed change in diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php
index b4ac3eff88..3123e533d5 100644
--- a/src/Type/Generic/GenericClassStringType.php
+++ b/src/Type/Generic/GenericClassStringType.php
@@ -219,6 +219,18 @@ class GenericClassStringType extends ClassStringType
if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) {
return new NeverType();
}
+
+ if ($classReflection->getAllowedSubTypes() !== null) {
+ $objectTypeToRemove = new ObjectType($typeToRemove->getValue());
+ $remainingType = TypeCombinator::remove($generic, $objectTypeToRemove);
+ if ($remainingType instanceof NeverType) {
+ return new NeverType();
+ }
+
+ if (!$remainingType->equals($generic)) {
+ return new self($remainingType);
+ }
+ }
}
} elseif (count($genericObjectClassNames) > 1) {
$objectTypeToRemove = new ObjectType($typeToRemove->getValue());
This is a valid regression test for MatchExpressionRule: <?php declare(strict_types = 1);
namespace Bug12241;
/**
* @phpstan-sealed Bar|Baz
*/
abstract class Foo{}
final class Bar extends Foo{}
final class Baz extends Foo{}
function (Foo $foo): string {
return match ($foo::class) {
Bar::class => 'Bar',
Baz::class => 'Baz',
};
};I'll accept the PR if you update it like this. The classes need to be final, otherwise the |
7d24fb7 to
b15a269
Compare
|
Thanks for the review! I tried applying your suggested patch, but it doesn't cover generic sealed types. The problem is that /**
* @template T
* @phpstan-sealed Bar|Baz
*/
abstract class Foo {}
/** @template T @extends Foo<T> */
final class Bar extends Foo {}
/** @template T @extends Foo<T> */
final class Baz extends Foo {}
/** @param Foo<string> $foo */
function test(Foo $foo): string {
return match ($foo::class) {
Bar::class => 'bar',
Baz::class => 'baz',
};
// Still reports: Match expression does not handle remaining value:
// class-string<Foo<string>>&literal-string
}Inside I was able to keep your approach by stripping the generic parameters before delegating to diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php
index b4ac3eff88..3a1b2c5e4f 100644
--- a/src/Type/Generic/GenericClassStringType.php
+++ b/src/Type/Generic/GenericClassStringType.php
@@ -219,6 +219,19 @@ class GenericClassStringType extends ClassStringType
if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) {
return new NeverType();
}
+
+ if ($classReflection->getAllowedSubTypes() !== null) {
+ $objectTypeToRemove = new ObjectType($typeToRemove->getValue());
+ $baseType = new ObjectType($genericObjectClassNames[0],
+ $generic instanceof SubtractableType ? $generic->getSubtractedType() : null);
+ $remainingType = TypeCombinator::remove($baseType, $objectTypeToRemove);
+ if ($remainingType instanceof NeverType) {
+ return new NeverType();
+ }
+
+ if (!$remainingType->equals($baseType)) {
+ return new self($remainingType);
+ }
+ }
}
} elseif (count($genericObjectClassNames) > 1) {
$objectTypeToRemove = new ObjectType($typeToRemove->getValue());If this is okay for you, I'll update the PR with this simplified version. The PR also includes a second commit that addresses a related issue we ran into: when narrowing a generic union via /** @template T @extends Animal<T> */
class Cat extends Animal {}
/** @template T @extends Animal<T> */
class Dog extends Animal {}
/** @param Cat<string>|Dog<string> $a */
function test(Animal $a): void {
if ($a::class === Cat::class) {
// Currently: $a becomes Cat (generic <string> lost)
// With fix: $a becomes Cat<string>
echo $a->value(); // should be string, not mixed
}
}This happens because |
|
What happens if you make the |
|
Thanks for your proposal! But I tested both, neither Here are the tests I ran. Except the first they fail with your original patch and pass with the generic-stripping approach: // --- a: non-generic ---
/** @phpstan-sealed Bar|Baz */
abstract class Foo{}
final class Bar extends Foo{}
final class Baz extends Foo{}
function (Foo $foo): string {
return match ($foo::class) {
Bar::class => 'Bar',
Baz::class => 'Baz',
};
};
// --- b: @template-covariant ---
/**
* @template-covariant T
* @phpstan-sealed BarCov|BazCov
*/
abstract class FooCov {}
/** @template-covariant T @extends FooCov<T> */
final class BarCov extends FooCov {}
/** @template-covariant T @extends FooCov<T> */
final class BazCov extends FooCov {}
/** @param FooCov<string> $foo */
function testTemplateCovariant(FooCov $foo): string {
return match ($foo::class) {
BarCov::class => 'bar',
BazCov::class => 'baz',
};
}
// --- c: @param Foo<covariant string> ---
/**
* @template T
* @phpstan-sealed BarInv|BazInv
*/
abstract class FooInv {}
/** @template T @extends FooInv<T> */
final class BarInv extends FooInv {}
/** @template T @extends FooInv<T> */
final class BazInv extends FooInv {}
/** @param FooInv<covariant string> $foo */
function testCovariantParam(FooInv $foo): string {
return match ($foo::class) {
BarInv::class => 'bar',
BazInv::class => 'baz',
};
} |
|
Please update the PR to the fix I pasted. And then we can open a new issue specific to generics. |
…s comparisons GenericClassStringType::tryRemove() did not handle @phpstan-sealed hierarchies, so match expressions on $foo::class falsely reported unhandled remaining values even when all allowed subtypes were covered. Delegates to TypeCombinator::remove() which already handles sealed subtraction via ObjectType::changeSubtractedType(). Closes phpstan/phpstan#12241 Co-Authored-By: Ondrej Mirtes <ondrej@mirtes.cz>
b15a269 to
74421a4
Compare
|
This pull request has been marked as ready for review. |
|
I updated this PR and will create a two new ones for generics: one for sealed + generic class-string exhaustiveness and one for generic parameter preservation during ::class narrowing. |
|
Thank you. |
Summary
match($foo::class)on a@phpstan-sealedhierarchy falsely reported "Match expression does not handle remaining value" even when all allowed subtypes were covered.Closes phpstan/phpstan#12241
Root Cause
GenericClassStringType::tryRemove()only handled final classes — it had no awareness of@phpstan-sealedhierarchies. Removing a class-string constant for a non-final sealed subtype was a no-op, so the type was never fully exhausted tonever.Fix
Extended
GenericClassStringType::tryRemove()to checkClassReflection::getAllowedSubTypes()and delegate toTypeCombinator::remove(), which already handles sealed subtraction viaObjectType::changeSubtractedType().Test Plan
MatchExpressionRuleTest::testBug12241— exhaustive match on sealed hierarchy produces no error