From 53a323073397e4cb7bcf5ed7643b9cd3d2688d65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:14:14 +0000 Subject: [PATCH] Fix phpstan/phpstan#13453: Disambiguate identical template type descriptions in error messages - When two different template types produce the same description at the chosen verbosity level, escalate to precise verbosity to include scope information - This fixes self-contradictory messages like "should return T of ResultA but returns T of ResultA" by showing "T of ResultA (function run(), argument) but returns T of ResultA (class I, parameter)" - New regression test in tests/PHPStan/Rules/Functions/data/bug-13453.php --- src/Type/VerbosityLevel.php | 16 +++++++- .../Rules/Functions/ReturnTypeRuleTest.php | 12 ++++++ .../Rules/Functions/data/bug-13453.php | 40 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13453.php diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 32be9683a81..2b6e16ad272 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -221,7 +221,13 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc }); if (!$containsInvariantTemplateType) { - return $verbosity ?? self::typeOnly(); + $level = $verbosity ?? self::typeOnly(); + + if ($acceptingType->describe($level) === $acceptedType->describe($level)) { + return self::precise(); + } + + return $level; } /** @var bool $moreVerbose */ @@ -234,7 +240,13 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc return self::precise(); } - return $moreVerbose ? self::value() : $verbosity ?? self::typeOnly(); + $level = $moreVerbose ? self::value() : $verbosity ?? self::typeOnly(); + + if ($acceptingType->describe($level) === $acceptedType->describe($level)) { + return self::precise(); + } + + return $level; } /** diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 6b63c91b88f..c5a1405d302 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -411,4 +411,16 @@ public function testBug12397(): void $this->analyse([__DIR__ . '/data/bug-12397.php'], []); } + public function testBug13453(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13453.php'], [ + [ + 'Function Bug13453\run() should return T of Bug13453\ResultA (function Bug13453\run(), argument) but returns T of Bug13453\ResultA (class Bug13453\I, parameter).', + 33, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13453.php b/tests/PHPStan/Rules/Functions/data/bug-13453.php new file mode 100644 index 00000000000..b3164230d3b --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13453.php @@ -0,0 +1,40 @@ += 8.3 + +declare(strict_types = 1); + +namespace Bug13453; + +/** @template T of ResultA */ +interface I { + /** @var class-string */ + public const string ResultType = ResultA::class; +} + +class ResultA { + public function __construct(public string $value) {} +} + +class ResultB extends ResultA { + public function rot13(): string { return str_rot13($this->value); } +} + +/** @template-implements I */ +class In implements I { + public const string ResultType = ResultB::class; +} + +/** + * @template T of ResultA + * @param I $in + * @return T + */ +function run(I $in): ResultA { + $value = 'abc'; + return new ($in::ResultType)($value); +} + +function main(): void { + $in = new In(); + $ret = run($in); + print $ret->rot13(); +}