From 27206227230ae232fe2b4cbdeeb96c0ce22015d0 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:22:59 +0000 Subject: [PATCH] Anchor template types inferred from invariant generic parameters instead of widening them into a union - GenericParametersAcceptorResolver::resolve() now runs an "anchor" pass before the main inference loop: template types that occur in an invariant position (e.g. T inside an invariant Column) are inferred first and substituted into the other parameters. The remaining occurrences of the same template type are then validated against the anchored type instead of being unioned into it. - As a result, `where(Column $column, T $value)` called with an `IntColumn` (`Column`) and a string now reports the mismatch on `$value` ("expects int, string given") rather than on `$column` ("expects Column<'...'|int>, IntColumn given"). - The anchored type is kept out of the parameter that anchored it, so dependent template types inferred through a template's bound (e.g. `@template TModel of ModelInterface`) are still resolved. - Covariant positions (e.g. `iterable`, `@template-covariant`) are unaffected and keep the existing union-widening behavior. - Applies uniformly to function, method and static-method calls (shared resolver). - Added GenericParametersAcceptorResolver::getInvariantTemplateTypeNames() to detect invariant template occurrences via getReferencedTemplateTypes(). --- .../GenericParametersAcceptorResolver.php | 70 ++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-14872.php | 52 ++++++++++++++ .../nsrt/generic-object-lower-bound.php | 4 +- .../Rules/Methods/CallMethodsRuleTest.php | 22 +++++- .../PHPStan/Rules/Methods/data/bug-14872.php | 58 +++++++++++++++ 5 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14872.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14872.php diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index d38396e9520..e3ad4a242cf 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -19,6 +19,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use function array_key_exists; +use function array_keys; use function array_last; use function array_map; use function array_merge; @@ -78,6 +79,45 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc ); } + // template types that appear in an invariant position (e.g. T inside an + // invariant Column) are determined exactly by that argument, so they + // are inferred first and substituted into the remaining parameter types — + // the other occurrences of the same template type are then validated + // against the anchored type instead of widening it into a union. + // e.g. in where(Column $column, T $value) called with IntColumn (Column) + // and 'foo', T becomes int and the error is reported on $value, not $column. + $anchorTypeMap = TemplateTypeMap::createEmpty(); + $invariantNamesByParam = []; + foreach ($parameters as $param) { + if (!isset($namedArgTypes[$param->getName()])) { + continue; + } + + $invariantNames = self::getInvariantTemplateTypeNames($param->getType()); + if (count($invariantNames) === 0) { + continue; + } + + $invariantNamesByParam[$param->getName()] = $invariantNames; + + $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + $inferred = $paramType->inferTemplateTypes($namedArgTypes[$param->getName()]); + $kept = []; + foreach ($inferred->getTypes() as $name => $type) { + if (!array_key_exists($name, $invariantNames)) { + continue; + } + + $kept[$name] = $type; + } + + if (count($kept) === 0) { + continue; + } + + $anchorTypeMap = $anchorTypeMap->union(new TemplateTypeMap($kept)); + } + foreach ($parameters as $param) { if (isset($namedArgTypes[$param->getName()])) { $argType = $namedArgTypes[$param->getName()]; @@ -89,12 +129,21 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc continue; } - $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + // Substitute the anchored types into the consumer parameters so they + // are validated against the anchored type, but keep them out of the + // parameter that anchored them — its own (possibly dependent) template + // types still need to be inferred from the original parameter type. + $paramSubstitutionMap = $predicateTypeMap->union($anchorTypeMap); + foreach (array_keys($invariantNamesByParam[$param->getName()] ?? []) as $name) { + $paramSubstitutionMap = $paramSubstitutionMap->unsetType($name); + } + + $paramType = self::resolvePredicateTemplateTypes($param->getType(), $paramSubstitutionMap); $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); $passedArgs['$' . $param->getName()] = $argType; } - $typeMap = $typeMap->union($predicateTypeMap); + $typeMap = $typeMap->union($predicateTypeMap)->union($anchorTypeMap); $returnType = $parametersAcceptor->getReturnType(); if ( @@ -165,6 +214,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc return $result; } + /** + * @return array names of template types that occur in an invariant position in $paramType + */ + private static function getInvariantTemplateTypeNames(Type $paramType): array + { + $names = []; + foreach ($paramType->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()) as $reference) { + if (!$reference->getPositionVariance()->invariant()) { + continue; + } + + $names[$reference->getType()->getName()] = true; + } + + return $names; + } + private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap { $typeMap = TemplateTypeMap::createEmpty(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14872.php b/tests/PHPStan/Analyser/nsrt/bug-14872.php new file mode 100644 index 00000000000..eb6634fdca3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14872.php @@ -0,0 +1,52 @@ + + */ +class IntColumn extends Column +{ +} + +/** + * @template T + * @param Column $column + * @param T $value + * @return T + */ +function where($column, $value) +{ +} + +function test(IntColumn $c): void +{ + // T is anchored to int by the invariant Column, not widened to int|'x'. + assertType('int', where($c, 'x')); + assertType('int', where($c, 5)); +} + +/** + * @template T + * @param iterable $a + * @param T $b + * @return T + */ +function covariantPosition($a, $b) +{ +} + +function testCovariant(): void +{ + // iterable value position is covariant, so T is still widened. + assertType("1|2|'x'", covariantPosition([1, 2], 'x')); +} diff --git a/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php b/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php index fe71aab3d65..a9223e21058 100644 --- a/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php +++ b/tests/PHPStan/Analyser/nsrt/generic-object-lower-bound.php @@ -45,7 +45,9 @@ function doFoo(Collection $c, object $d) */ function doBar(Collection $c): void { - assertType(Cat::class . '|' . Dog::class, $this->doFoo($c, new Cat())); + // T is anchored to Dog by the invariant Collection argument; + // the Cat passed as T $d is a parameter mismatch, not a widening of T. + assertType(Dog::class, $this->doFoo($c, new Cat())); } } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index a6e55ecbd30..f9360912b8d 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -987,6 +987,23 @@ public function testBug14720(): void $this->analyse([__DIR__ . '/data/bug-14720.php'], []); } + public function testBug14872(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14872.php'], [ + [ + 'Parameter #3 $value of method Bug14872\Builder::where() expects int, string given.', + 52, + ], + [ + 'Parameter #3 $value of method Bug14872\Builder::where() expects string, int given.', + 53, + ], + ]); + } + public function testClosureBind(): void { $this->checkThisOnly = false; @@ -2287,9 +2304,8 @@ public function testGenericObjectLowerBound(): void $this->checkUnionTypes = true; $this->analyse([__DIR__ . '/../../Analyser/nsrt/generic-object-lower-bound.php'], [ [ - 'Parameter #1 $c of method GenericObjectLowerBound\Foo::doFoo() expects GenericObjectLowerBound\Collection, GenericObjectLowerBound\Collection given.', - 48, - 'Template type T on class GenericObjectLowerBound\Collection is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', + 'Parameter #2 $d of method GenericObjectLowerBound\Foo::doFoo() expects GenericObjectLowerBound\Dog, GenericObjectLowerBound\Cat given.', + 50, ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14872.php b/tests/PHPStan/Rules/Methods/data/bug-14872.php new file mode 100644 index 00000000000..76150029c4a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14872.php @@ -0,0 +1,58 @@ + + */ +class IntColumn extends Column +{ +} + +/** + * @extends Column + */ +class StringColumn extends Column +{ +} + +class Board +{ + public IntColumn $id; + + public StringColumn $title; +} + +class Builder +{ + + /** + * @template T + * @param Column $column + * @param T $value + */ + public function where($column, string $operator, $value): self + { + return $this; + } + +} + +function test(Builder $b, Board $board): void +{ + // T is anchored to int by the invariant Column, so the mismatch + // is reported on the $value argument, not on $column. + $b->where($board->id, '=', 'test_string_value'); + $b->where($board->title, '=', 3); + + // matching values are accepted + $b->where($board->id, '=', 5); + $b->where($board->title, '=', 'ok'); +}