Do not traverse into GenericObjectType type arguments in RuleLevelHelper::transformAcceptedType()#5459
Conversation
…Helper::transformAcceptedType()` - When `checkNullables` is false (levels 0-7), `transformAcceptedType()` removes null from types via `TypeCombinator::removeNull()`. The traversal descended into `GenericObjectType`'s type arguments, removing null from them. This caused invariant template type checking (which uses `equals()`) to fail when comparing identical types like `Container<string|null>` vs `Container<string|null>`, because the accepted type's argument was transformed to `string` while the accepting type's argument remained `string|null`. - The fix stops traversal at `GenericObjectType` boundaries, preserving type arguments as-is. This is correct because generic type arguments represent template bindings whose identity must be preserved for invariant template comparison. - Verified the same bug affected method call parameters, property assignments, and closure return types — all use the same `RuleLevelHelper::accepts()` path and are fixed by this single change.
VincentLanglet
left a comment
There was a problem hiding this comment.
Ensure
/**
* @template T
*/
class Container
{
/** @var T */
public $value;
/**
* @param T $value
*/
public function __construct($value)
{
$this->value = $value;
}
}
class Foo
{
/** @return Container<string> */
public function test(?string $val): Container
{
return new Container($val);
}
}
is still not reported with checkNullables: false
…ObjectType type arguments Instead of completely blocking traversal into GenericObjectType, use traverseSimultaneously to pair up accepted and accepting type arguments. Only remove null from accepted type arguments where the corresponding accepting type argument does not contain null. This ensures that: - Container<string|null> accepted where Container<string|null> is expected does not produce a false positive (null not removed when accepting also has null) - Container<string|null> accepted where Container<string> is expected correctly has null removed (accepted matches after transformation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Changes have been committed and pushed. Here's a summary of what was done: Problem: The reviewer (VincentLanglet) pointed out that the original fix — which completely blocked traversal into Fix: Instead of returning
Test case added: The reviewer's exact example — Results: All 11,858 tests pass and |
VincentLanglet
left a comment
There was a problem hiding this comment.
What about using directly SimultaneousTypeTraverser::map instead of TypeTraverser::map ?
|
I can't post directly to the PR, but the instructions mention the results will be posted separately. Let me summarize my findings: SummaryAfter thorough investigation of VincentLanglet's suggestion to use Why
|
Summary
When
checkNullablesis false (rule levels 0–7),RuleLevelHelper::transformAcceptedType()traverses intoGenericObjectType's type arguments and removesnullfrom them. This causes invariant template type checking to produce false positives where the error message shows identical types on both sides (e.g. "should returnAttribute<string|null, never>but returnsAttribute<string|null, never>").Changes
RuleLevelHelper::transformAcceptedType()when the traversed type is aGenericObjectType, preventing theremoveNulltransformation from modifying generic type arguments (src/Rules/RuleLevelHelper.php)checkNullablesconfiguration field toReturnTypeRuleTest,TypesAssignedToPropertiesRuleTest, andClosureReturnTypeRuleTestto enable testing withcheckNullables: falseRoot cause
RuleLevelHelper::transformAcceptedType()usesTypeTraverser::map()to walk the accepted type tree. WhencheckNullablesis false, it removesnullfrom union types viaTypeCombinator::removeNull(). The traversal descends intoGenericObjectType::traverse(), which iterates over the generic type arguments. This means a type argument likestring|nullgets transformed tostringin the accepted type, while the accepting type's argument remainsstring|null.For invariant templates,
TemplateTypeVariance::isValidVariance()uses$a->equals($b)to compare type arguments. After the transformation,(string|null)->equals(string)returnsfalse, producing a false positive error.The fix stops the traversal at
GenericObjectTypeboundaries. This is correct because:removeNulllogic is meant for top-level type leniency at lower rule levels, not for modifying the internal structure of generic typesisSuperTypeOf()is used instead ofequals(), which handles nullable differences correctly without needing the transformationTest
ReturnTypeRuleTest::testBug12490— reproduces the original issue: method returningAttribute<string|null, never>,Attribute<float|null, never>,Attribute<int|null, never>withcheckNullables: falseCallMethodsRuleTest::testBug12490— analogous case for method call parameters withContainer<string|null>,Container<int|null>,Container<float|null>TypesAssignedToPropertiesRuleTest::testBug12490— analogous case for property assignmentsClosureReturnTypeRuleTest::testBug12490— analogous case for closure return typesAll four tests fail without the fix and pass with it.
Fixes phpstan/phpstan#12490