Erase generic type arguments when comparing a class-string against a constant class-string - #6485
phpstan-bot wants to merge 6 commits into
Conversation
staabm
left a comment
There was a problem hiding this comment.
lgtm - issue bot reports more stuff,.. not sure its related or an outdated result list
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression test for
…a constant class-string - A `class-string` value carries only a class name, never type arguments, so `GenericObjectType::isSuperTypeOf()` returning `Maybe` for an unparameterized class name must not make `'X'` a non-member of `class-string<X<int>>`. - Added `GenericClassStringType::isValueOfGenericType()`, which strips generic type arguments (via `TypeTraverser`) from the generic type before comparing it against `ObjectType($className)`, and used it from both places that did the constant class-string comparison by hand: `GenericClassStringType::isSuperTypeOf()` and `ConstantStringType::isSuperTypeOf()`. - Subtracted object types keep their precision because erasure only replaces `GenericObjectType` with its unparameterized `ObjectType` counterpart. - Fixes the same family of misses that all funnelled through those two methods: `class-string<X<*>>` star projections, unions nested inside a single `class-string<X<int>|Y<int>>`, `===` narrowing (which produced a stray `'X'&class-string<X<int>>` intersection), `switch`/`default` narrowing, `in_array()` narrowing, `match` exhaustiveness false positives, constant-array offset access keyed by `X::class` (reported "Offset does not exist" and `*ERROR*`), and `TypeCombinator::union()` failing to absorb `'X'` into `class-string<X<int>>`. - Probed and found already correct: `GenericClassStringType::accepts()` (an accepts context already ignores the type arguments of an unparameterized class name), `is_a()`/`is_subclass_of()` narrowing, `instanceof $class`, and template type inference from `X::class`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0379b43 to
4102d83
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
Fix the parity in the Turbo extension files .cpp
`isValueOfGenericType()` and its type argument erasure landed in the PHP GenericClassStringType, but both it and ConstantStringType are shadowed by the turbo extension, so the native mirrors still ran the old comparison — with the extension loaded the fix had no effect. GenericClassStringType.cpp gains the static method (registered under the twin's generated signature, so a `self::` call from PHP reaches it) with the erasure as a stateless native TypeTraverser::map() callback: a GenericObjectType becomes the ObjectType of the class it names, keeping the subtracted type. Its isSuperTypeOf() delegates to it, and ConstantStringType.cpp reaches the same body through the new pt_generic_class_string_is_value_of_generic_type() entry point. The ObjectType of the compared class name is built before the traversal: the twin takes `$className` by value, while the native borrows it from a property slot that the calls in between must not outlive. type-family.php covers the new method directly over an object, a parameterized, star-projected, subtracted and union generic type, a StaticType and a TemplateType, and adds parameterized, star-projected and union class-strings to the string family's subjects and comparison targets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Done. The review asked for turbo-ext parity, and both classes this PR touched (
One deliberate deviation from the twin's statement order: the
Verification: strict build clean under the CI warning set; Two things I could not run here:
|
Summary
A union of parameterized
class-stringtypes was not narrowed after a class name was excluded with!==:class-string<X<int>>|class-string<Y<int>>stayed unchanged instead of narrowing toclass-string<Y<int>>, while the equivalent union of rawclass-string<X>|class-string<Y>narrowed correctly. Star-projectedclass-string<X<*>>was affected the same way.The fix erases generic type arguments before a
class-stringis compared against a constant class-string, because aclass-stringvalue only ever carries a class name.Changes
src/Type/Generic/GenericClassStringType.php@internalstaticisValueOfGenericType(Type $genericType, string $className): IsSuperTypeOfResultholding the "can a class named$classNamebe the value behindclass-string<$genericType>" check that was previously duplicated in two places.eraseTypeArguments()which maps everyGenericObjectTypeinside the generic type to its unparameterizedObjectTypecounterpart (keeping the subtracted type) viaTypeTraverser.isSuperTypeOf()now delegates itsConstantStringTypebranch to the new helper.src/Type/Constant/ConstantStringType.phpGenericClassStringTypebranch ofisSuperTypeOf()delegates to the same helper instead of repeating theStaticType/TemplateType/ObjectTypedance.Analogous cases that were broken by the same root cause and are fixed by this change (each covered by a new assertion):
class-string<X<*>>|class-string<Y<*>>class-string—class-string<X<int>|Y<int>>===narrowing, which used to produce a stray'X'&class-string<X<int>>intersection instead of'X'switch/defaultarm narrowingin_array($class, [X::class], true)narrowingmatchexhaustiveness: "Match expression does not handle remaining values" false positive$map[$class]reported "Offset ... does not exist" and produced*ERROR*, now1|2and "might not exist" (same as for rawclass-string<X>)TypeCombinator::union()now absorbs'X'intoclass-string<X<int>>class-string<X<int>>&literal-stringnow subtracts to*NEVER*like its raw counterpartProbed and found already correct, so no change and no test kept:
GenericClassStringType::accepts()(an accepts context already treats an unparameterized class name as compatible with any parameterization),is_a()/is_subclass_of()narrowing,instanceof $class, and template type inference fromX::class.Root cause
ConstantStringType::isSuperTypeOf()andGenericClassStringType::isSuperTypeOf()both answered "isC::classa possible value ofclass-string<G>?" by turning the constant intonew ObjectType(C)and requiringG->isSuperTypeOf(ObjectType(C))to be a definiteYes. TheYesrequirement is deliberate — it filters out the uncertainty thatObjectType(C)could really be a subclass ofC.But when
Gis parameterized,GenericObjectType::isSuperTypeOf()can never returnYesfor an unparameterizedObjectType(C): it looks upC's ancestor with the same class name, finds a plainObjectType, and downgrades the result toMaybebecause the type arguments are unknown. So the check collapsed toNo,TypeCombinator::remove()bailed out at itsisSuperType->no()early return and never reachedGenericClassStringType::tryRemove(), and no narrowing happened.A
class-stringvalue is just a class name and cannot carry type arguments, so that particular uncertainty must not count against the match. The fix erases the type arguments from the generic type before the comparison, which makesclass-string<X<int>>andclass-string<X<*>>behave exactly likeclass-string<X>in this comparison. Erasure — rather than switching to an accepts context — is what keeps the existing precision for subtracted object types such asclass-string<Type~(ConstantIntegerType|IntegerRangeType)>, where theMaybegenuinely comes from the subtraction and not from type arguments.Test
tests/PHPStan/Analyser/nsrt/bug-15266.php— the playground reproducer verbatim (parameterized, star-projected and raw unions), plus one function per analogous case listed above. All 11 new assertions fail without the source change.tests/PHPStan/Rules/Comparison/data/bug-15266.phpandMatchExpressionRuleTest::testBug15266()— amatchover a parameterized/star-projectedclass-stringunion is expected to report nothing; without the fix it reports "Match expression does not handle remaining values" twice.Fixes phpstan/phpstan#15266
Fixes phpstan/phpstan#10498