From 87770ba373e3bd324ae02ad7c228a34fb6989b38 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 19 Sep 2026 00:25:16 +0800 Subject: [PATCH 1/2] TDD test for replication of problem identified in github issue --- tests/Fixtures/Forwarding/AbstractBox.php | 18 ++++++++++++ tests/Fixtures/Forwarding/BoxConsumer.php | 18 ++++++++++++ tests/Fixtures/Forwarding/BoxInterface.php | 13 +++++++++ tests/Fixtures/Forwarding/ConcreteBox.php | 12 ++++++++ tests/Fixtures/Forwarding/ItemA.php | 12 ++++++++ tests/Fixtures/Forwarding/ItemB.php | 12 ++++++++ tests/Fixtures/Forwarding/ItemBase.php | 9 ++++++ .../GenericForwardingInheritanceTest.php | 29 +++++++++++++++++++ 8 files changed, 123 insertions(+) create mode 100644 tests/Fixtures/Forwarding/AbstractBox.php create mode 100644 tests/Fixtures/Forwarding/BoxConsumer.php create mode 100644 tests/Fixtures/Forwarding/BoxInterface.php create mode 100644 tests/Fixtures/Forwarding/ConcreteBox.php create mode 100644 tests/Fixtures/Forwarding/ItemA.php create mode 100644 tests/Fixtures/Forwarding/ItemB.php create mode 100644 tests/Fixtures/Forwarding/ItemBase.php create mode 100644 tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php diff --git a/tests/Fixtures/Forwarding/AbstractBox.php b/tests/Fixtures/Forwarding/AbstractBox.php new file mode 100644 index 0000000..9bb6f8f --- /dev/null +++ b/tests/Fixtures/Forwarding/AbstractBox.php @@ -0,0 +1,18 @@ + + */ +abstract class AbstractBox implements BoxInterface +{ +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/BoxConsumer.php b/tests/Fixtures/Forwarding/BoxConsumer.php new file mode 100644 index 0000000..0faa570 --- /dev/null +++ b/tests/Fixtures/Forwarding/BoxConsumer.php @@ -0,0 +1,18 @@ + $box + */ + public function __construct(public readonly BoxInterface $box) + { + } +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/BoxInterface.php b/tests/Fixtures/Forwarding/BoxInterface.php new file mode 100644 index 0000000..ba67c3d --- /dev/null +++ b/tests/Fixtures/Forwarding/BoxInterface.php @@ -0,0 +1,13 @@ + + */ +final class ConcreteBox extends AbstractBox +{ +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/ItemA.php b/tests/Fixtures/Forwarding/ItemA.php new file mode 100644 index 0000000..03be03a --- /dev/null +++ b/tests/Fixtures/Forwarding/ItemA.php @@ -0,0 +1,12 @@ +box)->toBe($concreteBox); + }); + + test('reifies bound generic types on concrete box instance matching both interface and class templates', function () { + $concreteBox = new ConcreteBox(); + + expect(TypePHP::getGenericType($concreteBox, 'T'))->toBe(ItemA::class) + ->and(TypePHP::getGenericType($concreteBox, 'U'))->toBe(ItemB::class) + ; + }); +}); \ No newline at end of file From cf14616d8207cc424f4a2455dfc0ae784664ab4b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 19 Sep 2026 00:44:50 +0800 Subject: [PATCH 2/2] Enhance BoxConsumer with generic methods and type validation tests and implement fix on the bug --- src/Internal/Checker/ParamChecker.php | 38 ++++++++++++- tests/Fixtures/Forwarding/BoxConsumer.php | 54 +++++++++++++++++++ .../GenericForwardingInheritanceTest.php | 40 +++++++++++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 3d57bf6..6cf75b3 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -18,6 +18,7 @@ use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; +use TypePHP\Internal\Docblock\DocblockExtractor; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; @@ -25,6 +26,7 @@ use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\ClassNameValidator; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Util\StubManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; /** @@ -51,6 +53,13 @@ final class ParamChecker */ private static array $baseTypeCache = []; + /** + * Cache for declared class template names by class FQCN. + * + * @var array> + */ + private static array $classDeclaredTemplateNamesCache = []; + /** * Resets internal caches. Useful for test isolation. */ @@ -58,6 +67,7 @@ public static function reset(): void { self::$effectiveFunctionCache = []; self::$noParamContractCache = []; + self::$classDeclaredTemplateNamesCache = []; ClassNameValidator::reset(); self::$baseTypeCache = []; } @@ -803,6 +813,25 @@ private static function inferGenericObjectNode( return; } + $targetClassFqcn = SpecialTypeResolver::resolveFqcn($typeNode->type->name, new \ReflectionClass($value)); + $targetTemplateNames = self::$classDeclaredTemplateNamesCache[$targetClassFqcn] ?? null; + + if ($targetTemplateNames === null) { + $targetTemplateNames = []; + if (class_exists($targetClassFqcn, false) || class_exists($targetClassFqcn) || interface_exists($targetClassFqcn) || trait_exists($targetClassFqcn)) { + try { + $targetRef = new \ReflectionClass($targetClassFqcn); + $targetDoc = StubManager::getClassDoc($targetClassFqcn) ?? $targetRef->getDocComment(); + if ($targetDoc !== false && $targetDoc !== null) { + $targetPhpDocNode = DocblockExtractor::parseDocString($targetDoc); + $targetTemplateNames = array_keys(DocblockExtractor::extractTemplates($targetPhpDocNode)); + } + } catch (\Throwable $e) { + } + } + self::$classDeclaredTemplateNamesCache[$targetClassFqcn] = $targetTemplateNames; + } + $instanceBoundTypes = array_values($boundOnInstance); foreach ($typeNode->genericTypes as $idx => $gtNode) { @@ -812,7 +841,14 @@ private static function inferGenericObjectNode( $targetObj = $isClassLevel ? $thisObj : null; if (! TemplateManager::isBound($effectiveFunction, $targetObj, $tName)) { - $inferredCandidate = $instanceBoundTypes[$idx] ?? null; + $targetTemplateName = $targetTemplateNames[$idx] ?? null; + $inferredCandidate = null; + + if ($targetTemplateName !== null && isset($boundOnInstance[$targetTemplateName])) { + $inferredCandidate = $boundOnInstance[$targetTemplateName]; + } elseif (isset($instanceBoundTypes[$idx])) { + $inferredCandidate = $instanceBoundTypes[$idx]; + } if ($inferredCandidate !== null) { $templateTag = $templates[$tName]; diff --git a/tests/Fixtures/Forwarding/BoxConsumer.php b/tests/Fixtures/Forwarding/BoxConsumer.php index 0faa570..44e7171 100644 --- a/tests/Fixtures/Forwarding/BoxConsumer.php +++ b/tests/Fixtures/Forwarding/BoxConsumer.php @@ -15,4 +15,58 @@ final class BoxConsumer public function __construct(public readonly BoxInterface $box) { } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return T + */ + public function extractFirst(BoxInterface $box): ItemBase + { + return new ItemA(); + } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return U + */ + public function extractSecond(BoxInterface $box): ItemBase + { + return new ItemB(); + } + + /** + * Method returning invalid type (returns ItemB when T is ItemA) + * + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return T + */ + public function extractBad(BoxInterface $box): ItemBase + { + return new ItemB(); + } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return BoxInterface + */ + public function passThrough(BoxInterface $box): BoxInterface + { + return $box; + } } \ No newline at end of file diff --git a/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php b/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php index 778fe89..a4534ce 100644 --- a/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php +++ b/tests/TypeChecking/Generics/GenericForwardingInheritanceTest.php @@ -4,6 +4,7 @@ namespace TypePHP\Tests\TypeChecking\Generics; +use TypePHP\Exception\TypeError; use TypePHP\Tests\Fixtures\Forwarding\BoxConsumer; use TypePHP\Tests\Fixtures\Forwarding\ConcreteBox; use TypePHP\Tests\Fixtures\Forwarding\ItemA; @@ -13,7 +14,6 @@ describe('Generic Template Forwarding Across Abstract Hierarchies with Shifted Template Indexes', function () { test('infers method template arguments T and U from implementing class when abstract parent has extra leading template', function () { $concreteBox = new ConcreteBox(); - $consumer = new BoxConsumer($concreteBox); expect($consumer->box)->toBe($concreteBox); @@ -26,4 +26,42 @@ ->and(TypePHP::getGenericType($concreteBox, 'U'))->toBe(ItemB::class) ; }); + + describe('Return Type Contracts with Forwarded Generics', function () { + test('validates @return T correctly uses inferred ItemA', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $first = $consumer->extractFirst($concreteBox); + expect($first)->toBeInstanceOf(ItemA::class); + }); + + test('validates @return U correctly uses inferred ItemB', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $second = $consumer->extractSecond($concreteBox); + expect($second)->toBeInstanceOf(ItemB::class); + }); + + test('throws TypeError when @return T returns ItemB instead of inferred ItemA', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + expect(fn () => $consumer->extractBad($concreteBox)) + ->toThrow( + TypeError::class, + 'Return value must be of type ' . ItemA::class . ', ' . ItemB::class . ' returned' + ) + ; + }); + + test('validates @return BoxInterface returning the forwarded concrete instance', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $result = $consumer->passThrough($concreteBox); + expect($result)->toBe($concreteBox); + }); + }); }); \ No newline at end of file