diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 8e9a71d..b4a5531 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -301,7 +301,22 @@ private function handleAssign(Node\Expr\Assign $node): void $typeString = $this->scopeManager->getVarTypeFromScope($varName); if ($typeString !== null) { - $node->expr = $this->wrapVariableCheck($node->expr, $typeString, $varName, $node->var->getStartLine()); + $expr = $node->expr; + if ($expr instanceof Node\Expr\New_ && str_contains($typeString, '<')) { + $expr = new Node\Expr\StaticCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'), + 'withPendingGeneric', + [ + new Node\Arg(new Node\Scalar\String_($typeString)), + new Node\Arg(new Node\Expr\ArrowFunction([ + 'expr' => $expr, + ])), + new Node\Arg(new Node\Scalar\MagicConst\File()), + ] + ); + } + + $node->expr = $this->wrapVariableCheck($expr, $typeString, $varName, $node->var->getStartLine()); } } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { $node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 37da681..4685911 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -362,6 +362,13 @@ final class TemplateManager */ private static array $isMethodTemplateCache = []; + /** + * Stack storing pending generic instantiations for constructors. + * + * @var list + */ + private static array $pendingInstantiations = []; + /** * Resets all static generic template bindings, call stack frames, and method template caches. */ @@ -375,6 +382,46 @@ public static function reset(): void self::$pendingCloneSource = null; self::$methodTemplatesCache = []; self::$isMethodTemplateCache = []; + self::$pendingInstantiations = []; + } + + /** + * Pushes a pending generic instantiation for a constructor. + */ + public static function pushPendingInstantiation(string $typeString, string $file): void + { + $pos = strpos($typeString, '<'); + $rawClass = $pos !== false ? trim(substr($typeString, 0, $pos)) : $typeString; + $targetClass = SpecialTypeResolver::resolveFqcnForFile($rawClass, $file); + + self::$pendingInstantiations[] = [ + 'typeString' => $typeString, + 'file' => $file, + 'targetClass' => $targetClass, + ]; + } + + /** + * Pops the last pending generic instantiation for a constructor. + */ + public static function popPendingInstantiation(): void + { + array_pop(self::$pendingInstantiations); + } + + /** + * Applies the last pending generic instantiation for a constructor to the given instance. + */ + public static function applyPendingInstantiation(object $instance): void + { + if (self::$pendingInstantiations === []) { + return; + } + + $pending = end(self::$pendingInstantiations); + if ($pending !== false && is_a($instance, $pending['targetClass'])) { + self::bindInstance($instance, $pending['typeString'], $pending['file']); + } } /** diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index f38e442..3cc4f71 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -207,7 +207,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof GenericTypeNode) { $genericType = self::resolve($node->type, $context, $thisObj); - $innerTypes = array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); + $innerTypes = array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -261,11 +261,11 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); } return $node; @@ -299,7 +299,7 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode if ($node instanceof GenericTypeNode) { $genericType = self::resolveForFile($node->type, $file); - $innerTypes = array_map(fn($t) => self::resolveForFile($t, $file), $node->genericTypes); + $innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -353,11 +353,11 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); + return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); } return clone $node; diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 0879bda..e54f0e3 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -55,6 +55,22 @@ public static function isEnabled(): bool return Config::isEnabled(); } + /** + * Pre-binds generic template state on a class before its constructor executes. + * + * @param \Closure(): mixed $factory + */ + public static function withPendingGeneric(string $typeString, \Closure $factory, string $file = ''): mixed + { + TemplateManager::pushPendingInstantiation($typeString, $file); + + try { + return $factory(); + } finally { + TemplateManager::popPendingInstantiation(); + } + } + /** * Delegates generic template binding for class instances. */ @@ -146,6 +162,10 @@ public static function setupScope(string $function, array $vars, object|string|n return null; } + if ($thisObj !== null && str_ends_with($effectiveFunction, '::__construct')) { + TemplateManager::applyPendingInstantiation($thisObj); + } + if ( isset(ParamChecker::$noParamContractCache[$function]) && ! (self::$hasMethodTemplatesCache[$function] ?? false) diff --git a/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php index 35d1f24..ca4d4cd 100644 --- a/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php +++ b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php @@ -39,12 +39,10 @@ function fetchBroadTuple(int $id, string $name): array describe('mixed type validation with @var and param', function () { test('enforces stricter inline @var annotation over broader function return contract', function () { - // Valid call: [10, 'Alice'] satisfies both @return and @var /** @var array{0: positive-int, 1: non-empty-string} $userData */ $userData = fetchBroadTuple(10, 'Alice'); expect($userData[0])->toBe(10); - // Invalid call: [-5, 'Alice'] satisfies @return (int), BUT violates @var (positive-int) expect(function () { /** @var array{0: positive-int, 1: non-empty-string} $userData */ $userData = fetchBroadTuple(-5, 'Alice'); @@ -202,6 +200,11 @@ function fetchBroadTuple(int $id, string $name): array expect($producer->item)->toBeInstanceOf(Cat::class); expect(fn () => $producer = new Producer(new Car())) + ->toThrow(TypeError::class, 'Argument $item (template T =') + ; + + $carProducer = new Producer(new Car()); + expect(fn () => $producer = $carProducer) ->toThrow(TypeError::class, 'Variable $producer') ; }); diff --git a/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php b/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php new file mode 100644 index 0000000..f9346f7 --- /dev/null +++ b/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php @@ -0,0 +1,84 @@ +content; + } +} + +describe('Constructor Generic Pre-binding with Inline @var Annotation', function () { + test('prebinds generic template to instance before constructor executes and rejects invalid items', function () { + expect(function () { + /** @var ConstructorPrebindBox $box */ + $box = new ConstructorPrebindBox([1, 2, '3']); + })->toThrow( + TypeError::class, + 'Argument $content[0] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal' + ); + }); + + test('accepts valid items matching pre-bound template in constructor', function () { + $dog = new ConstructorPrebindDog(); + $cat = new ConstructorPrebindCat(); + + /** @var ConstructorPrebindBox $box */ + $box = new ConstructorPrebindBox([$dog, $cat]); + + expect($box->getContent())->toHaveCount(2) + ->and($box->getContent()[0])->toBe($dog) + ->and($box->getContent()[1])->toBe($cat) + ->and(TypePHP::getGenericType($box))->toBe(ConstructorPrebindAnimal::class) + ; + }); + + test('rejects items violating pre-bound template in constructor even if first item is a valid subtype', function () { + $dog = new ConstructorPrebindDog(); + $car = new ConstructorPrebindCar(); + + expect(function () use ($dog, $car) { + /** @var ConstructorPrebindBox $box */ + $box = new ConstructorPrebindBox([$dog, $car]); + })->toThrow( + TypeError::class, + 'Argument $content[1] (template T = TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal) must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindAnimal' + ); + }); +});