From bf055b7f22fb77e250788e7979488160079c4c00 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 00:30:33 +0800 Subject: [PATCH 01/10] Implement self-out functionality with associated checks and docblock extraction --- src/Internal/Ast/FunctionContractInjector.php | 73 ++++-- src/Internal/Checker/SelfOutChecker.php | 93 ++++++++ src/Internal/Docblock/DocblockExtractor.php | 50 ++++ src/Internal/Docblock/DocblockParser.php | 32 ++- src/Internal/Generics/TemplateManager.php | 8 + src/Internal/RuntimeTypeChecker.php | 18 ++ .../Internal/Wrapper/CallableWrapperTest.php | 2 +- .../Boundaries/ParamOutContractsTest.php | 3 +- .../Generics/SelfOutStateTransitionTest.php | 214 ++++++++++++++++++ 9 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 src/Internal/Checker/SelfOutChecker.php create mode 100644 tests/TypeChecking/Generics/SelfOutStateTransitionTest.php diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 9810516..7a6d95d 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -77,6 +77,9 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $hasParamOut = $byRefParams !== [] && ($hasParamOutDoc || $hasInheritance); + $hasSelfOutDoc = str_contains($docText, 'self-out') || str_contains($docText, 'this-out'); + $hasSelfOut = $isClassMethod && ! $node->isStatic() && ($hasSelfOutDoc || $hasInheritance); + $hasReturnDoc = str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'); @@ -86,7 +89,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? && ! ($isNativeVoid && ! $hasReturnDoc) && self::hasReturnContracts($docText, $isClassMethod, $isPrivate); - if (! $hasParam && ! $hasReturn && ! $hasParamOut) { + if (! $hasParam && ! $hasReturn && ! $hasParamOut && ! $hasSelfOut) { return; } @@ -100,7 +103,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg); } - if ($hasReturn || $hasParamOut) { + if ($hasReturn || $hasParamOut || $hasSelfOut) { $node->stmts = self::isGenerator($node) ? self::wrapGeneratorReturns($node->stmts, $thisArg) : self::wrapNonGeneratorReturns( @@ -109,13 +112,35 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $isNativeVoid, $needsReturnVars, $hasReturn, - $hasParamOut ? $byRefParams : [] + $hasParamOut ? $byRefParams : [], + $hasSelfOut ); } $node->stmts = [...$injectedStmts, ...$node->stmts]; } + public static function buildSelfOutCheckStmt(Node\Expr $thisArg, bool $needsReturnVars = false): Node\Stmt\Expression + { + $varsArg = $needsReturnVars + ? new Node\Expr\Variable('_typephpArgs') + : new Node\Expr\Array_(); + + $checkCall = new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::checkSelfOut'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg($thisArg), + new Node\Arg($varsArg), + ] + ); + + $stmt = new Node\Stmt\Expression($checkCall); + $stmt->setAttribute('typephp_injected', true); + + return $stmt; + } + private static function resolveDocComment(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): ?Doc { $doc = $node->getDocComment(); @@ -699,10 +724,11 @@ private static function wrapNonGeneratorReturns( bool $isNativeVoid, bool $needsReturnVars = false, bool $hasReturn = true, - array $byRefParams = [] + array $byRefParams = [], + bool $hasSelfOut = false ): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { /** * @param array $byRefParams */ @@ -711,7 +737,8 @@ public function __construct( private bool $isNativeVoid, private bool $needsReturnVars, private bool $hasReturn, - private array $byRefParams + private array $byRefParams, + private bool $hasSelfOut ) { } @@ -726,12 +753,21 @@ public function enterNode(Node $n): int|array|null return null; } + $exitStmts = []; + if ($this->hasSelfOut) { + $exitStmts[] = FunctionContractInjector::buildSelfOutCheckStmt($this->thisArg, $this->needsReturnVars); + } + $paramOutStmts = $this->byRefParams !== [] ? FunctionContractInjector::buildParamOutCheckStmts($this->byRefParams, $this->thisArg) : []; + if ($paramOutStmts !== []) { + $exitStmts = [...$exitStmts, ...$paramOutStmts]; + } + if (! $this->hasReturn) { - return $paramOutStmts !== [] ? [...$paramOutStmts, $n] : null; + return $exitStmts !== [] ? [...$exitStmts, $n] : null; } $exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null')); @@ -740,7 +776,7 @@ public function enterNode(Node $n): int|array|null $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); $voidGuardStmts = FunctionContractInjector::buildVoidReturnGuard($checkCall); - return [...$paramOutStmts, ...$voidGuardStmts]; + return [...$exitStmts, ...$voidGuardStmts]; } // Call-site cache bypass for return checks @@ -764,7 +800,7 @@ public function enterNode(Node $n): int|array|null $ternaryExpr ); - return $paramOutStmts !== [] ? [...$paramOutStmts, $n] : null; + return $exitStmts !== [] ? [...$exitStmts, $n] : null; } return null; @@ -776,21 +812,28 @@ public function enterNode(Node $n): int|array|null $lastStmt = end($newStmts); if (! $lastStmt instanceof Node\Stmt\Return_ && ! ($lastStmt instanceof Node\Stmt\Expression && $lastStmt->expr instanceof Node\Expr\Throw_)) { + $exitStmts = []; + if ($hasSelfOut) { + $exitStmts[] = self::buildSelfOutCheckStmt($thisArg, $needsReturnVars); + } + $paramOutStmts = $byRefParams !== [] ? self::buildParamOutCheckStmts($byRefParams, $thisArg) : []; + if ($paramOutStmts !== []) { + $exitStmts = [...$exitStmts, ...$paramOutStmts]; + } + if (! $hasReturn) { - if ($paramOutStmts !== []) { - $retStmt = new Node\Stmt\Return_(null); - $retStmt->setAttribute('typephp_injected', true); - $newStmts = [...$newStmts, ...$paramOutStmts, $retStmt]; + if ($exitStmts !== []) { + $newStmts = [...$newStmts, ...$exitStmts]; } } else { $checkCall = self::buildReturnCheckCall(new Node\Expr\ConstFetch(new Node\Name('null')), $thisArg, $needsReturnVars); if ($isNativeVoid) { - $newStmts = [...$newStmts, ...$paramOutStmts, ...self::buildVoidReturnGuard($checkCall)]; + $newStmts = [...$newStmts, ...$exitStmts, ...self::buildVoidReturnGuard($checkCall)]; } else { $cacheKeyExpr = new Node\Scalar\MagicConst\Method(); $cacheCheck = new Node\Expr\Isset_([ @@ -812,7 +855,7 @@ public function enterNode(Node $n): int|array|null $retStmt = new Node\Stmt\Return_($fallbackExpr); $retStmt->setAttribute('typephp_injected', true); - $newStmts = [...$newStmts, ...$paramOutStmts, $retStmt]; + $newStmts = [...$newStmts, ...$exitStmts, $retStmt]; } } } diff --git a/src/Internal/Checker/SelfOutChecker.php b/src/Internal/Checker/SelfOutChecker.php new file mode 100644 index 0000000..7922bf4 --- /dev/null +++ b/src/Internal/Checker/SelfOutChecker.php @@ -0,0 +1,93 @@ + + */ + public static array $noSelfOutContractCache = []; + + public static function reset(): void + { + self::$noSelfOutContractCache = []; + } + + /** + * @param array $vars + */ + public static function checkSelfOut( + string $function, + object $thisObj, + array $vars, + TypeValidatorRegistry $registry, + string $effectiveFunction = '' + ): void { + if (! Config::isEnabled()) { + return; + } + + if (isset(self::$noSelfOutContractCache[$function])) { + return; + } + + if ($effectiveFunction === '') { + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisObj, $thisObj); + } + + if (isset(self::$noSelfOutContractCache[$effectiveFunction])) { + self::$noSelfOutContractCache[$function] = true; + + return; + } + + $contract = DocblockParser::parse($effectiveFunction); + + if (! ($contract['hasSelfOutContract'] ?? false) || $contract['selfOut'] === null) { + self::$noSelfOutContractCache[$effectiveFunction] = true; + self::$noSelfOutContractCache[$function] = true; + + return; + } + + $selfOutNode = $contract['selfOut']; + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $boundTemplates = (\count($allTemplates) > 0) + ? TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates) + : []; + + if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { + $selfOutNode = TemplateSubstitutor::substitute($selfOutNode, $boundTemplates, $allTemplates); + $selfOutNode = SpecialTypeResolver::resolve($selfOutNode, $effectiveFunction, $thisObj); + } + + if ( + $selfOutNode instanceof ConditionalTypeForParameterNode || + $selfOutNode instanceof ConditionalTypeNode + ) { + $selfOutNode = ConditionalChecker::resolve($selfOutNode, $vars, $boundTemplates, $registry, $effectiveFunction); + } + + if ($selfOutNode instanceof GenericTypeNode) { + TemplateManager::bindInstanceFromNode($thisObj, $selfOutNode, forceBind: true); + } + } +} diff --git a/src/Internal/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index 5c114cf..6df4b7f 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -4,11 +4,13 @@ namespace TypePHP\Internal\Docblock; +use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\SelfOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -169,6 +171,54 @@ public static function getParamOutTags(PhpDocNode $node): array return $tags; } + /** + * Extracts self-out / this-out tags with priority: @phpstan-* > @psalm-* > standard. + */ + public static function getSelfOutTag(PhpDocNode $node): ?TypeNode + { + $phpstanTags = array_merge( + $node->getTagsByName('@phpstan-self-out'), + $node->getTagsByName('@phpstan-this-out') + ); + foreach ($phpstanTags as $tag) { + if ($tag->value instanceof SelfOutTagValueNode) { + return $tag->value->type; + } + } + + $psalmTags = array_merge( + $node->getTagsByName('@psalm-self-out'), + $node->getTagsByName('@psalm-this-out') + ); + foreach ($psalmTags as $tag) { + if ($tag->value instanceof SelfOutTagValueNode) { + return $tag->value->type; + } + } + + $standardTags = array_merge( + $node->getTagsByName('@self-out'), + $node->getTagsByName('@this-out') + ); + foreach ($standardTags as $tag) { + if ($tag->value instanceof SelfOutTagValueNode) { + return $tag->value->type; + } + if ($tag->value instanceof GenericTagValueNode) { + try { + [$typeParser, $lexer] = self::getTypeParserComponents(); + $tokens = new TokenIterator($lexer->tokenize($tag->value->value)); + + return $typeParser->parse($tokens); + } catch (\Throwable $e) { + // Silently ignore malformed tags + } + } + } + + return null; + } + /** * Extracts return tag with priority: @phpstan-return > @psalm-return > @return. */ diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index f4f83d0..479b784 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -42,12 +42,14 @@ final class DocblockParser * @var array, * paramOuts: array, + * selfOut: ?TypeNode, * templates: array, * classTemplates: array, * return: ?TypeNode, * aliases: array, * hasParamContract: bool, * hasParamOutContract: bool, + * hasSelfOutContract: bool, * hasReturnContract: bool, * paramsUseGenerics: bool, * returnUsesGenerics: bool, @@ -331,12 +333,14 @@ private static function computeContractFlags( * @return array{ * types: array, * paramOuts: array, + * selfOut: ?TypeNode, * templates: array, * classTemplates: array, * return: ?TypeNode, * aliases: array, * hasParamContract: bool, * hasParamOutContract: bool, + * hasSelfOutContract: bool, * hasReturnContract: bool, * paramsUseGenerics: bool, * returnUsesGenerics: bool, @@ -371,12 +375,14 @@ public static function parse(string $function): array $contract = [ 'types' => [], 'paramOuts' => [], + 'selfOut' => null, 'templates' => [], 'classTemplates' => $classTemplates, 'return' => null, 'aliases' => $aliases, 'hasParamContract' => false, 'hasParamOutContract' => false, + 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, @@ -392,12 +398,14 @@ public static function parse(string $function): array $contract = [ 'types' => [], 'paramOuts' => [], + 'selfOut' => null, 'templates' => [], 'classTemplates' => [], 'return' => null, 'aliases' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, + 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, @@ -417,12 +425,14 @@ public static function parse(string $function): array $contract = [ 'types' => [], 'paramOuts' => [], + 'selfOut' => null, 'templates' => [], 'classTemplates' => [], 'return' => null, 'aliases' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, + 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, @@ -779,12 +789,14 @@ public static function parseClassAliases(string $className): array * @return array{ * types: array, * paramOuts: array, + * selfOut: ?TypeNode, * templates: array, * classTemplates: array, * return: ?TypeNode, * aliases: array, * hasParamContract: bool, * hasParamOutContract: bool, + * hasSelfOutContract: bool, * hasReturnContract: bool, * paramsUseGenerics: bool, * returnUsesGenerics: bool, @@ -803,10 +815,11 @@ private static function parseMethod(\ReflectionMethod $ref): array $methodTemplates = []; $classTemplates = []; $returnType = null; + $selfOut = null; $aliases = []; self::parseClassLevelDocs($ref->getDeclaringClass(), $classTemplates, $aliases); - self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts); + self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts, $selfOut); if ($ref->getName() === '__construct') { self::applyConstructorPromotionFallback($ref, $types, $classTemplates, $aliases); @@ -831,6 +844,11 @@ private static function parseMethod(\ReflectionMethod $ref): array } } } + if (! $paramsUseGenerics && $selfOut !== null) { + if (self::typeReferencesTemplate($selfOut, $allTemplates)) { + $paramsUseGenerics = true; + } + } } $returnUsesMethodTemplates = false; @@ -865,12 +883,14 @@ private static function parseMethod(\ReflectionMethod $ref): array return [ 'types' => $types, 'paramOuts' => $paramOuts, + 'selfOut' => $selfOut, 'templates' => $methodTemplates, 'classTemplates' => $classTemplates, 'return' => $returnType, 'aliases' => $aliases, 'hasParamContract' => \count($types) > 0, 'hasParamOutContract' => \count($paramOuts) > 0, + 'hasSelfOutContract' => $selfOut !== null, 'hasReturnContract' => $returnType !== null, 'paramsUseGenerics' => $paramsUseGenerics, 'returnUsesGenerics' => $returnUsesGenerics, @@ -1123,6 +1143,7 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar * @param TypeNode|null $returnType * @param array $aliases * @param array $paramOuts + * @param TypeNode|null $selfOut */ private static function parseMethodHierarchyDocs( \ReflectionMethod $ref, @@ -1130,7 +1151,8 @@ private static function parseMethodHierarchyDocs( array &$templates, ?TypeNode &$returnType, array &$aliases, - array &$paramOuts = [] + array &$paramOuts = [], + ?TypeNode &$selfOut = null ): void { $hierarchy = HierarchyResolver::getMethodHierarchy($ref); $baseParams = $ref->getParameters(); @@ -1233,6 +1255,12 @@ private static function parseMethodHierarchyDocs( } } + $selfOutType = DocblockExtractor::getSelfOutTag($phpDocNode); + if ($selfOutType !== null && $selfOut === null) { + $substituted = self::substituteAliases($selfOutType, $aliases); + $selfOut = SpecialTypeResolver::resolve($substituted, $hierRef); + } + if ($returnType === null) { $returnTag = DocblockExtractor::getReturnTag($phpDocNode); if ($returnTag !== null) { diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index d4bfe4a..67aa3a7 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -714,6 +714,14 @@ private static function bindSingleTemplateArgument( $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); if (! $valid) { + if ($forceBind) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; + + return null; + } + $isDefaultOrBound = ($existingTypeNode instanceof IdentifierTypeNode) && ( strtolower($existingTypeNode->name) === 'mixed' || strtolower($existingTypeNode->name) === 'array-key' diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 2a91299..00a48e2 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -12,6 +12,7 @@ use TypePHP\Internal\Checker\ParamChecker; use TypePHP\Internal\Checker\ParamOutChecker; use TypePHP\Internal\Checker\ReturnChecker; +use TypePHP\Internal\Checker\SelfOutChecker; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; @@ -46,6 +47,7 @@ public static function reset(): void IgnoreManager::reset(); CallerBoundaryResolver::reset(); ParamOutChecker::reset(); + SelfOutChecker::reset(); } /** @@ -243,6 +245,22 @@ public static function checkParamOut(string $function, string $paramName, mixed return $res; } + /** + * Re-types generic template state on $this upon method exit (@self-out, @phpstan-self-out, @psalm-self-out). + * + * @param array|null $vars + */ + public static function checkSelfOut(string $function, object $thisObj, ?array $vars = []): void + { + if (! Config::isEnabled()) { + return; + } + + $vars ??= []; + + SelfOutChecker::checkSelfOut($function, $thisObj, $vars, self::getRegistry()); + } + /** * Validates a function or method's return value against its declared contract and returns value or ErrorMessage. * diff --git a/tests/Internal/Wrapper/CallableWrapperTest.php b/tests/Internal/Wrapper/CallableWrapperTest.php index 7e819fc..e033d65 100644 --- a/tests/Internal/Wrapper/CallableWrapperTest.php +++ b/tests/Internal/Wrapper/CallableWrapperTest.php @@ -174,4 +174,4 @@ public function __invoke(): void expect(CallableWrapper::isCallable('not_callable_123'))->toBeFalse(); expect(CallableWrapper::isCallable(123))->toBeFalse(); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/ParamOutContractsTest.php b/tests/TypeChecking/Boundaries/ParamOutContractsTest.php index 96ad1ff..98a0529 100644 --- a/tests/TypeChecking/Boundaries/ParamOutContractsTest.php +++ b/tests/TypeChecking/Boundaries/ParamOutContractsTest.php @@ -400,7 +400,8 @@ function tddPsalmOverridesStandardParamOut(mixed &$code): void ; expect(fn () => tddParamOutShapeComposition($data, ['id' => 10, 'name' => 'Alice', 'extra' => true])) - ->toThrow(TypeError::class, "Argument &\$data (param-out) contains unsealed unexpected key 'extra'"); + ->toThrow(TypeError::class, "Argument &\$data (param-out) contains unsealed unexpected key 'extra'") + ; }); }); }); diff --git a/tests/TypeChecking/Generics/SelfOutStateTransitionTest.php b/tests/TypeChecking/Generics/SelfOutStateTransitionTest.php new file mode 100644 index 0000000..e8f251e --- /dev/null +++ b/tests/TypeChecking/Generics/SelfOutStateTransitionTest.php @@ -0,0 +1,214 @@ + + */ + public function login(): void + { + // Mutates session in place + } + + /** + * @self-out self<'unauthenticated'> + */ + public function logout(): void + { + // Mutates session back to unauthenticated + } +} + +/** + * Helper function demanding an authenticated session + * + * @param FixtureSession<'authenticated'> $session + */ +function tddRequireAuth(FixtureSession $session): bool +{ + return true; +} + +/** + * 2. Tooling Priority Test: @phpstan-self-out overrides @self-out + * + * @template TState of string + */ +class FixturePrioritySession +{ + /** + * @self-out self<'standard_state'> + * + * @psalm-self-out self<'psalm_state'> + * + * @phpstan-self-out self<'phpstan_state'> + */ + public function transition(): void + { + } +} + +/** + * 3. Mutable Collection Accumulating Generic Types + * + * @template T + */ +class FixtureMutableCollection +{ + /** + * @var array + */ + public array $items = []; + + /** + * Here, we accept an item of type T (so it guards against invalid additions!) + * + * @param T $item + */ + public function addStrict(mixed $item): void + { + $this->items[] = $item; + } + + /** + * Here, we accumulate a new type TItem into T. + * + * @template TItem + * + * @param TItem $item + * + * @phpstan-self-out self + */ + public function addDynamic(mixed $item): void + { + $this->items[] = $item; + } +} + +/** + * 4. Fluent Builder chaining with @this-out + * + * @template TStep of 'init'|'configured'|'ready' + */ +class FixtureFluentBuilder +{ + /** + * @this-out self<'configured'> + */ + public function configure(): self + { + return $this; + } + + /** + * @this-out self<'ready'> + */ + public function prepare(): self + { + return $this; + } +} + +/** + * @param FixtureFluentBuilder<'ready'> $builder + */ +function tddRequireReadyBuilder(FixtureFluentBuilder $builder): bool +{ + return true; +} + +describe('@self-out, @phpstan-self-out & @psalm-self-out Transitions (TDD Baseline)', function () { + describe('1. State Machine Transition (@self-out)', function () { + test('re-types generic template on $this in WeakMap after method execution', function () { + /** @var FixtureSession<'unauthenticated'> $session */ + $session = new FixtureSession(); + + expect(TypePHP::getGenericType($session))->toBe("'unauthenticated'"); + + expect(fn () => tddRequireAuth($session)) + ->toThrow(TypeError::class, "FixtureSession") + ; + $session->login(); + + expect(TypePHP::getGenericType($session))->toBe("'authenticated'"); + + expect(tddRequireAuth($session))->toBeTrue(); + }); + + test('transitions state back upon calling logout()', function () { + /** @var FixtureSession<'unauthenticated'> $session */ + $session = new FixtureSession(); + + $session->login(); + expect(tddRequireAuth($session))->toBeTrue(); + + $session->logout(); + expect(TypePHP::getGenericType($session))->toBe("'unauthenticated'"); + + expect(fn () => tddRequireAuth($session)) + ->toThrow(TypeError::class) + ; + }); + }); + + describe('2. Tooling Priority Hierarchy (@phpstan-self-out > @psalm-self-out > @self-out)', function () { + test('prioritizes @phpstan-self-out over psalm and standard tags', function () { + /** @var FixturePrioritySession<'init'> $session */ + $session = new FixturePrioritySession(); + + $session->transition(); + + expect(TypePHP::getGenericType($session))->toBe("'phpstan_state'"); + }); + }); + + describe('3. Dynamic Template Accumulation (self)', function () { + test('accumulates union types in WeakMap when method adds new type to generic container', function () { + /** @var FixtureMutableCollection $col */ + $col = new FixtureMutableCollection(); + expect(TypePHP::getGenericType($col))->toBe(Dog::class); + + $col->addDynamic(new Cat()); + + expect(TypePHP::getGenericType($col))->toBe('(' . Dog::class . ' | ' . Cat::class . ')'); + + $col->addStrict(new Dog()); + expect(\count($col->items))->toBe(2); + + expect(fn () => $col->addStrict(new Car())) + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')') + ; + }); + }); + + describe('4. Fluent Builder Chaining with @this-out', function () { + test('updates generic state across method chains returning $this', function () { + /** @var FixtureFluentBuilder<'init'> $builder */ + $builder = new FixtureFluentBuilder(); + + expect(fn () => tddRequireReadyBuilder($builder)) + ->toThrow(TypeError::class) + ; + + $builder->configure()->prepare(); + + expect(TypePHP::getGenericType($builder))->toBe("'ready'"); + expect(tddRequireReadyBuilder($builder))->toBeTrue(); + }); + }); +}); From bf3a7940d6b128ecbf5a2964b5e59909549798f9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 01:57:11 +0800 Subject: [PATCH 02/10] Enhance self-out ignore resolution and vendor calling boundary isolation --- src/Internal/Checker/InlineChecker.php | 2 +- src/Internal/Docblock/DocblockParser.php | 14 ++++++++++---- src/Internal/RuntimeTypeChecker.php | 22 +++++++++++++++++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index c28cdbf..171f6e7 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -168,7 +168,7 @@ public static function checkVariable( $checkGenerics = (bool) ($config['generics'] ?? true); if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) { - $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, forceBind: true); + $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context); if ($err !== null) { return $err; } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 479b784..44e0151 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -909,12 +909,14 @@ private static function parseMethod(\ReflectionMethod $ref): array * @return array{ * types: array, * paramOuts: array, + * selfOut: ?TypeNode, * templates: array, * classTemplates: array, * return: ?TypeNode, * aliases: array, * hasParamContract: bool, * hasParamOutContract: bool, + * hasSelfOutContract: bool, * hasReturnContract: bool, * paramsUseGenerics: bool, * returnUsesGenerics: bool, @@ -942,12 +944,14 @@ private static function parseFunction(\ReflectionFunction $ref): array return [ 'types' => [], 'paramOuts' => [], + 'selfOut' => null, 'templates' => [], 'classTemplates' => [], 'return' => null, 'aliases' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, + 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, @@ -1068,12 +1072,14 @@ private static function parseFunction(\ReflectionFunction $ref): array return [ 'types' => $types, 'paramOuts' => $paramOuts, + 'selfOut' => null, 'templates' => $templates, 'classTemplates' => [], 'return' => $returnType, 'aliases' => $aliases, 'hasParamContract' => \count($types) > 0, 'hasParamOutContract' => \count($paramOuts) > 0, + 'hasSelfOutContract' => false, 'hasReturnContract' => $returnType !== null, 'paramsUseGenerics' => $paramsUseGenerics, 'returnUsesGenerics' => $returnUsesMethodTemplates, @@ -1561,7 +1567,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -1595,7 +1601,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -1612,7 +1618,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1627,7 +1633,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 00a48e2..cbc266c 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -256,9 +256,29 @@ public static function checkSelfOut(string $function, object $thisObj, ?array $v return; } + if (isset(SelfOutChecker::$noSelfOutContractCache[$function])) { + return; + } + + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisObj, $thisObj); + + if (isset(SelfOutChecker::$noSelfOutContractCache[$effectiveFunction])) { + SelfOutChecker::$noSelfOutContractCache[$function] = true; + + return; + } + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return; + } + + if (IgnoreManager::isCallerIgnored()) { + return; + } + $vars ??= []; - SelfOutChecker::checkSelfOut($function, $thisObj, $vars, self::getRegistry()); + SelfOutChecker::checkSelfOut($function, $thisObj, $vars, self::getRegistry(), $effectiveFunction); } /** From cb8aee11fcf5a6e5e87e1d0eb27543dc7e4632da Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 02:07:23 +0800 Subject: [PATCH 03/10] Add self-out configuration and validation checks --- src/Internal/Checker/SelfOutChecker.php | 2 +- src/Internal/Cli/ConfigInitCommand.php | 3 +- src/Internal/Docblock/DocblockParser.php | 8 ++--- src/Internal/Util/Config.php | 14 ++++++++ .../Configuration/BoundaryConfigTest.php | 36 +++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Internal/Checker/SelfOutChecker.php b/src/Internal/Checker/SelfOutChecker.php index 7922bf4..4a9d3a2 100644 --- a/src/Internal/Checker/SelfOutChecker.php +++ b/src/Internal/Checker/SelfOutChecker.php @@ -41,7 +41,7 @@ public static function checkSelfOut( TypeValidatorRegistry $registry, string $effectiveFunction = '' ): void { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || ! Config::isSelfOutEnabled()) { return; } diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index f670c6f..f8baed8 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -55,7 +55,7 @@ private static function getTemplate(): string /* |-------------------------------------------------------------------------- - | Function Boundary Contracts (@param, @return, @param-out) + | Function Boundary Contracts (@param, @return, @param-out, @self-out) |-------------------------------------------------------------------------- | Controls whether function and method parameter, return, and by-reference | out-parameter contracts are enforced at runtime. @@ -65,6 +65,7 @@ private static function getTemplate(): string 'params' => true, 'returns' => true, 'params_out' => true, + 'self_out' => true, /* |-------------------------------------------------------------------------- diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 44e0151..74ffd8f 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -1567,7 +1567,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -1601,7 +1601,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -1618,7 +1618,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1633,7 +1633,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 44a4eef..bcff1a3 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -60,6 +60,8 @@ final class Config private static bool $paramsOut = true; + private static bool $selfOut = true; + private static string $arrayValidation = 'full'; public static function isEnabled(): bool @@ -89,6 +91,15 @@ public static function isParamsOutEnabled(): bool return self::$params && self::$paramsOut; } + public static function isSelfOutEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$selfOut; + } + public static function isParamsEnabled(): bool { if (self::$cachedConfig === null) { @@ -250,6 +261,7 @@ public static function get(): array 'params' => true, 'returns' => true, 'params_out' => true, + 'self_out' => true, 'strict_return_generic_invariance' => true, 'magic_properties' => true, 'magic_methods' => true, @@ -393,6 +405,7 @@ public static function reset(): void self::$enabled = true; self::$params = true; self::$returns = true; + self::$selfOut = true; self::$strictReturnGenericInvariance = true; self::$magicProperties = true; self::$magicMethods = true; @@ -429,6 +442,7 @@ private static function syncFlags(array $config): void self::$enabled = (bool) ($config['enabled'] ?? true); self::$params = (bool) ($config['params'] ?? true); self::$paramsOut = (bool) ($config['params_out'] ?? true); + self::$selfOut = (bool) ($config['self_out'] ?? true); self::$returns = (bool) ($config['returns'] ?? true); self::$strictReturnGenericInvariance = (bool) ($config['strict_return_generic_invariance'] ?? true); self::$magicProperties = (bool) ($config['magic_properties'] ?? true); diff --git a/tests/TypeChecking/Configuration/BoundaryConfigTest.php b/tests/TypeChecking/Configuration/BoundaryConfigTest.php index 6d04bc3..1231c9f 100644 --- a/tests/TypeChecking/Configuration/BoundaryConfigTest.php +++ b/tests/TypeChecking/Configuration/BoundaryConfigTest.php @@ -7,6 +7,7 @@ use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Generics\GenericCollection; +use TypePHP\TypePHP; /** * Function with ONLY a parameter contract @@ -74,6 +75,19 @@ function testMixedParamAndParamOutFunction(int $code, mixed &$val): void $val = -50; } +/** + * @template TState of 'unauthenticated'|'authenticated' + */ +class SelfOutBoundaryConfigFixture +{ + /** + * @self-out self<'authenticated'> + */ + public function login(): void + { + } +} + describe('Function Boundary Config Toggles (params & returns)', function () { afterEach(function () { Config::reset(); @@ -131,6 +145,28 @@ function testMixedParamAndParamOutFunction(int $code, mixed &$val): void testParamOutConfigFunction($val); expect($val)->toBe(-100); }); + + test('bypasses @self-out generic transitions when self_out is set to false', function () { + Config::set(['self_out' => false]); + + /** @var SelfOutBoundaryConfigFixture<'unauthenticated'> $session */ + $session = new SelfOutBoundaryConfigFixture(); + + $session->login(); + + expect(TypePHP::getGenericType($session))->toBe("'unauthenticated'"); + }); + + test('enforces @self-out generic transitions when self_out is set to true', function () { + Config::set(['self_out' => true]); + + /** @var SelfOutBoundaryConfigFixture<'unauthenticated'> $session */ + $session = new SelfOutBoundaryConfigFixture(); + + $session->login(); + + expect(TypePHP::getGenericType($session))->toBe("'authenticated'"); + }); }); describe('Respect Native Nullability Configuration (respect_native_nullability)', function () { From 1df6049721428be194c2ae811fb4c0274358de77 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 02:48:14 +0800 Subject: [PATCH 04/10] Resolve discovred bugs and improve test coverage --- src/Internal/Ast/ContractVisitor.php | 4 +- src/Internal/Docblock/DocblockExtractor.php | 100 ++- src/Internal/Docblock/DocblockNormalizer.php | 8 + tests/Contract/DocblockExtractorTest.php | 369 ----------- tests/Internal/Checker/SelfOutCheckerTest.php | 258 ++++++++ .../Docblock/DocblockExtractorTest.php | 576 ++++++++++++++++++ .../Docblock/DocblockNormalizerTest.php | 44 ++ ...tParserTest.php => DocblockParserTest.php} | 0 8 files changed, 955 insertions(+), 404 deletions(-) delete mode 100644 tests/Contract/DocblockExtractorTest.php create mode 100644 tests/Internal/Checker/SelfOutCheckerTest.php create mode 100644 tests/Internal/Docblock/DocblockExtractorTest.php rename tests/Internal/Docblock/{ContractParserTest.php => DocblockParserTest.php} (100%) diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 6f5196e..8e9a71d 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -291,7 +291,7 @@ private function handleExpression(Node\Stmt\Expression $node): ?array } } - return $checkStmts !== [] ? array_merge([$node], $checkStmts) : null; + return $checkStmts !== [] ? [$node, ...$checkStmts] : null; } private function handleAssign(Node\Expr\Assign $node): void @@ -517,7 +517,7 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ 'expr' => $item->value, ]; } elseif ($item->value instanceof Node\Expr\List_ || $item->value instanceof Node\Expr\Array_) { - $vars = array_merge($vars, $this->extractDestructuringVariables($item->value)); + $vars = [...$vars, ...$this->extractDestructuringVariables($item->value)]; } } diff --git a/src/Internal/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index 6df4b7f..6b4e6fa 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -12,6 +12,8 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\SelfOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -176,30 +178,30 @@ public static function getParamOutTags(PhpDocNode $node): array */ public static function getSelfOutTag(PhpDocNode $node): ?TypeNode { - $phpstanTags = array_merge( - $node->getTagsByName('@phpstan-self-out'), - $node->getTagsByName('@phpstan-this-out') - ); + $phpstanTags = [ + ...$node->getTagsByName('@phpstan-self-out'), + ...$node->getTagsByName('@phpstan-this-out'), + ]; foreach ($phpstanTags as $tag) { if ($tag->value instanceof SelfOutTagValueNode) { return $tag->value->type; } } - $psalmTags = array_merge( - $node->getTagsByName('@psalm-self-out'), - $node->getTagsByName('@psalm-this-out') - ); + $psalmTags = [ + ...$node->getTagsByName('@psalm-self-out'), + ...$node->getTagsByName('@psalm-this-out'), + ]; foreach ($psalmTags as $tag) { if ($tag->value instanceof SelfOutTagValueNode) { return $tag->value->type; } } - $standardTags = array_merge( - $node->getTagsByName('@self-out'), - $node->getTagsByName('@this-out') - ); + $standardTags = [ + ...$node->getTagsByName('@self-out'), + ...$node->getTagsByName('@this-out'), + ]; foreach ($standardTags as $tag) { if ($tag->value instanceof SelfOutTagValueNode) { return $tag->value->type; @@ -441,29 +443,53 @@ public static function extractVarTagFromDoc(string $doc): ?array } /** - * Extracts local and imported type aliases (@phpstan-type and @phpstan-import-type) from a PHPDoc node. - * - * @param array $aliases - * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref - */ + * Extracts local and imported type aliases (@phpstan-type/@psalm-type and @phpstan-import-type/@psalm-import-type) from a PHPDoc node. + * Prioritizes @phpstan-* > @psalm-* within the same docblock, and child class overrides over parent classes across inheritance. + * + * @param array $aliases + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ public static function extractAliases( PhpDocNode $phpDocNode, array &$aliases, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref ): void { - foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) { - if (! isset($aliases[$aliasTag->alias])) { - $aliases[$aliasTag->alias] = $aliasTag->type; + $currentDocAliases = []; + + foreach ($phpDocNode->getTagsByName('@psalm-type') as $tag) { + if ($tag->value instanceof TypeAliasTagValueNode) { + $currentDocAliases[$tag->value->alias] = $tag->value->type; + } + } + + foreach ($phpDocNode->getTagsByName('@phpstan-type') as $tag) { + if ($tag->value instanceof TypeAliasTagValueNode) { + $currentDocAliases[$tag->value->alias] = $tag->value->type; } } - foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { - $localName = $importTag->importedAs ?? $importTag->importedAlias; - if (! isset($aliases[$localName])) { - $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); - $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); - if ($resolvedType !== null) { - $aliases[$localName] = $resolvedType; + foreach ($currentDocAliases as $name => $type) { + if (! isset($aliases[$name])) { + $aliases[$name] = $type; + } + } + + $importTags = [ + ...$phpDocNode->getTagsByName('@psalm-import-type'), + ...$phpDocNode->getTagsByName('@phpstan-import-type'), + ]; + + foreach ($importTags as $tag) { + if ($tag->value instanceof TypeAliasImportTagValueNode) { + $importTag = $tag->value; + $localName = $importTag->importedAs ?? $importTag->importedAlias; + + if (! isset($aliases[$localName])) { + $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); + if ($resolvedType !== null) { + $aliases[$localName] = $resolvedType; + } } } } @@ -474,7 +500,7 @@ public static function extractAliases( } /** - * Resolves an imported type alias (@phpstan-import-type) from a target class, interface, trait, or enum. + * Resolves an imported type alias (@phpstan-import-type / @psalm-import-type) from a target class, interface, trait, or enum. */ public static function resolveImportedTypeAlias(string $fqcn, string $importedAlias): ?TypeNode { @@ -497,12 +523,20 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl return $targetAliases[$importedAlias]; } - foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { - $localName = $importTag->importedAs ?? $importTag->importedAlias; - if ($localName === $importedAlias) { - $nextFqcn = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + $importTags = [ + ...$phpDocNode->getTagsByName('@psalm-import-type'), + ...$phpDocNode->getTagsByName('@phpstan-import-type'), + ]; - return self::resolveImportedTypeAlias($nextFqcn, $importTag->importedAlias); + foreach ($importTags as $tag) { + if ($tag->value instanceof TypeAliasImportTagValueNode) { + $importTag = $tag->value; + $localName = $importTag->importedAs ?? $importTag->importedAlias; + if ($localName === $importedAlias) { + $nextFqcn = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + + return self::resolveImportedTypeAlias($nextFqcn, $importTag->importedAlias); + } } } } diff --git a/src/Internal/Docblock/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index eb5f181..b13b8e8 100644 --- a/src/Internal/Docblock/DocblockNormalizer.php +++ b/src/Internal/Docblock/DocblockNormalizer.php @@ -38,6 +38,14 @@ public static function normalize(string $doc): string $doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc; } + if (str_contains($doc, '@self-out') && ! str_contains($doc, '@phpstan-self-out') && ! str_contains($doc, '@psalm-self-out')) { + $doc = preg_replace('/@self-out\b/', '@phpstan-self-out', $doc) ?? $doc; + } + + if (str_contains($doc, '@this-out') && ! str_contains($doc, '@phpstan-this-out') && ! str_contains($doc, '@psalm-this-out')) { + $doc = preg_replace('/@this-out\b/', '@phpstan-this-out', $doc) ?? $doc; + } + if (str_contains($doc, 'callable') || str_contains($doc, 'Closure')) { $doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc; } diff --git a/tests/Contract/DocblockExtractorTest.php b/tests/Contract/DocblockExtractorTest.php deleted file mode 100644 index 538f45b..0000000 --- a/tests/Contract/DocblockExtractorTest.php +++ /dev/null @@ -1,369 +0,0 @@ -toBeInstanceOf(PhpDocNode::class) - ->and(\count($node->getParamTagValues()))->toBe(1) - ; - }); - - test('extracts @template tags from docblock node', function () { - $doc = '/** @template T of \TypePHP\Tests\Fixtures\Domain\Animal */'; - $node = DocblockExtractor::parseDocString($doc); - - $templates = DocblockExtractor::extractTemplates($node); - - expect($templates)->toHaveKey('T') - ->and($templates['T']->name)->toBe('T') - ; - }); - - test('extracts property promotion type from property @var docblock', function () { - $doc = '/** @var string[] $strings */'; - $typeNode = DocblockExtractor::extractTypeFromPropertyDoc($doc, 'strings'); - - expect($typeNode)->not()->toBeNull(); - }); - - test('extracts local @phpstan-type aliases', function () { - $doc = '/** @phpstan-type StatusType "active"|"pending" */'; - $node = DocblockExtractor::parseDocString($doc); - $aliases = []; - - $ref = new ReflectionClass(UserService::class); - DocblockExtractor::extractAliases($node, $aliases, $ref); - - expect($aliases)->toHaveKey('StatusType'); - }); - - test('resolves imported type aliases with @phpstan-import-type', function () { - $doc = '/** @phpstan-import-type SharedShape from GlobalTypes as LocalUserShape */'; - $node = DocblockExtractor::parseDocString($doc); - $aliases = []; - - $ref = new ReflectionClass(UserApi::class); - DocblockExtractor::extractAliases($node, $aliases, $ref); - - expect($aliases)->toHaveKey('LocalUserShape'); - }); - - test('resolves imported type aliases from Enums', function () { - $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues'); - - expect($resolvedNode)->not()->toBeNull() - ->and((string) $resolvedNode)->toContain('histogram') - ; - }); - - test('resolves multi-tier chained imported type aliases (A -> B -> C)', function () { - $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(NestedAliasChainedB::class, 'MidShape'); - - expect($resolvedNode)->not()->toBeNull() - ->and((string) $resolvedNode)->toContain('positive-int') - ->and((string) $resolvedNode)->toContain('non-empty-string') - ; - }); - - test('fully expands nested alias dependencies when extracting aliases from a class', function () { - $ref = new ReflectionClass(NestedAliasService::class); - $doc = $ref->getDocComment(); - expect($doc)->not()->toBeFalse(); - - $phpDocNode = DocblockExtractor::parseDocString($doc); - $aliases = []; - - DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref); - - expect($aliases)->toHaveKey('LocalRecordList') - ->and((string) $aliases['LocalRecordList'])->toContain('positive-int') - ->and((string) $aliases['LocalRecordList'])->toContain('active') - ; - }); - - test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () { - $doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list \$tags\n */"; - - $scoreType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'score'); - expect((string) $scoreType)->toBe('positive-int'); - - $titleType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'title'); - expect((string) $titleType)->toBe('non-empty-string'); - - $tagsType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'tags'); - expect((string) $tagsType)->toBe('list'); - - $missingType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'missing'); - expect($missingType)->toBeNull(); - }); - - describe('Prioritized Tag Extractions (@phpstan-* > @psalm-* > standard)', function () { - test('prioritizes @phpstan-param over @psalm-param and @param', function () { - $doc = <<<'DOC' -/** - * @param mixed $element - * @psalm-param int $element - * @phpstan-param positive-int $element - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $paramTags = DocblockExtractor::getParamTags($node); - - expect($paramTags)->toHaveKey('element') - ->and((string) $paramTags['element']->type)->toBe('positive-int') - ; - }); - - test('prioritizes @psalm-param over @param when @phpstan-param is absent', function () { - $doc = <<<'DOC' -/** - * @param mixed $element - * @psalm-param non-empty-string $element - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $paramTags = DocblockExtractor::getParamTags($node); - - expect($paramTags)->toHaveKey('element') - ->and((string) $paramTags['element']->type)->toBe('non-empty-string') - ; - }); - - test('prioritizes @phpstan-return over @psalm-return and @return', function () { - $doc = <<<'DOC' -/** - * @return mixed - * @psalm-return array - * @phpstan-return list - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $returnTag = DocblockExtractor::getReturnTag($node); - - expect($returnTag)->not()->toBeNull() - ->and((string) $returnTag->type)->toBe('list') - ; - }); - - test('prioritizes @psalm-return over @return when @phpstan-return is absent', function () { - $doc = <<<'DOC' -/** - * @return mixed - * @psalm-return array{id: positive-int} - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $returnTag = DocblockExtractor::getReturnTag($node); - - expect($returnTag)->not()->toBeNull() - ->and((string) $returnTag->type)->toBe('array{id: positive-int}') - ; - }); - - test('prioritizes @phpstan-var over @psalm-var and @var', function () { - $doc = <<<'DOC' -/** - * @var mixed $item - * @psalm-var int $item - * @phpstan-var positive-int $item - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $varTags = DocblockExtractor::getVarTags($node); - - expect($varTags)->toHaveCount(1) - ->and((string) $varTags[0]->type)->toBe('positive-int') - ; - }); - - test('prioritizes @psalm-var over @var when @phpstan-var is absent', function () { - $doc = <<<'DOC' -/** - * @var mixed $item - * @psalm-var non-empty-string $item - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $varTags = DocblockExtractor::getVarTags($node); - - expect($varTags)->toHaveCount(1) - ->and((string) $varTags[0]->type)->toBe('non-empty-string') - ; - }); - }); -}); - -describe('@template Priority and Variance Extractions', function () { - test('prioritizes @phpstan-template with bound over basic @template', function () { - $doc = <<<'DOC' -/** - * @template T - * @phpstan-template T of \TypePHP\Tests\Fixtures\Domain\Animal - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $templates = DocblockExtractor::extractTemplates($node); - - expect($templates)->toHaveKey('T') - ->and($templates['T']->bound)->not()->toBeNull() - ->and((string) $templates['T']->bound)->toBe('\TypePHP\Tests\Fixtures\Domain\Animal') - ; - }); - - test('extracts declared template variances with @phpstan-template-covariant priority', function () { - $doc = <<<'DOC' -/** - * @template T - * @phpstan-template-covariant T - * @psalm-template-contravariant K - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $variances = DocblockExtractor::extractTemplateVariances($node); - - expect($variances)->toBe([ - 'T' => 'covariant', - 'K' => 'contravariant', - ]); - }); - - test('extracts all inherited template tag variations via getInheritedTags', function () { - $doc = <<<'DOC' -/** - * @template-extends BaseRepository - * @phpstan-implements ProcessorInterface - * @use LoggerTrait - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $inherited = DocblockExtractor::getInheritedTags($node); - - expect($inherited)->toHaveCount(3); - }); - - test('resolves imported type aliases defined inside stub files', function () { - $tempDir = sys_get_temp_dir() . '/typephp_doc_stub_' . uniqid(); - mkdir($tempDir, 0777, true); - - $stubPath = $tempDir . '/HelperService.stub'; - $stubContent = <<<'PHP' - [ - str_replace('\\', '/', $tempDir) . '/**', - ], - ]); - - $resolved = DocblockExtractor::resolveImportedTypeAlias(HelperService::class, 'StubbedUserShape'); - - expect($resolved)->not()->toBeNull() - ->and((string) $resolved)->toContain('positive-int') - ->and((string) $resolved)->toContain('non-empty-string') - ; - } finally { - if (file_exists($stubPath)) { - @unlink($stubPath); - } - if (is_dir($tempDir)) { - @rmdir($tempDir); - } - TypePHP\Internal\Util\Config::reset(); - } - }); - - test('preserves multiple variable @var tags when mixed with @phpstan-var and @psalm-var', function () { - $doc = <<<'DOC' -/** - * @phpstan-var positive-int $id - * @var non-empty-string $username - * @psalm-var 'admin'|'user' $role - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $varTags = DocblockExtractor::getVarTags($node); - - expect($varTags)->toHaveCount(3); - - $tagsByName = []; - foreach ($varTags as $tag) { - $tagsByName[ltrim($tag->variableName, '$')] = (string) $tag->type; - } - - expect($tagsByName)->toHaveKey('id') - ->and($tagsByName['id'])->toBe('positive-int') - ->and($tagsByName)->toHaveKey('username') - ->and($tagsByName['username'])->toBe('non-empty-string') - ->and($tagsByName)->toHaveKey('role') - ->and($tagsByName['role'])->toBe("('admin' | 'user')") - ; - }); - - describe('@param-out Tag Extractions (@param-out, @phpstan-param-out, @psalm-param-out)', function () { - test('extracts basic @param-out tag', function () { - $doc = '/** @param-out positive-int $id */'; - $node = DocblockExtractor::parseDocString($doc); - $tags = DocblockExtractor::getParamOutTags($node); - - expect($tags)->toHaveKey('id') - ->and((string) $tags['id']->type)->toBe('positive-int') - ; - }); - - test('prioritizes @phpstan-param-out over @psalm-param-out and @param-out', function () { - $doc = <<<'DOC' -/** - * @param-out mixed $id - * @psalm-param-out int $id - * @phpstan-param-out positive-int $id - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $tags = DocblockExtractor::getParamOutTags($node); - - expect($tags)->toHaveKey('id') - ->and((string) $tags['id']->type)->toBe('positive-int') - ; - }); - - test('prioritizes @psalm-param-out over @param-out when @phpstan-param-out is absent', function () { - $doc = <<<'DOC' -/** - * @param-out mixed $id - * @psalm-param-out non-empty-string $id - */ -DOC; - $node = DocblockExtractor::parseDocString($doc); - $tags = DocblockExtractor::getParamOutTags($node); - - expect($tags)->toHaveKey('id') - ->and((string) $tags['id']->type)->toBe('non-empty-string') - ; - }); - }); -}); diff --git a/tests/Internal/Checker/SelfOutCheckerTest.php b/tests/Internal/Checker/SelfOutCheckerTest.php new file mode 100644 index 0000000..0e1dfa5 --- /dev/null +++ b/tests/Internal/Checker/SelfOutCheckerTest.php @@ -0,0 +1,258 @@ + + */ + public function login(): void + { + } + + /** + * @self-out self<'unauthenticated'> + */ + public function logout(): void + { + } + + public function noSelfOutMethod(): void + { + } +} + +/** + * Fixture: Accumulating new generic types into existing template + * + * @template T + */ +class FixtureSelfOutAccumulator +{ + /** + * @self-out self + */ + public function addCat(): void + { + } +} + +/** + * Fixture: Conditional self-out based on method arguments + * + * @template TRole of 'admin'|'guest' + */ +class FixtureConditionalSelfOut +{ + /** + * @param bool $asAdmin + * + * @self-out ($asAdmin is true ? self<'admin'> : self<'guest'>) + */ + public function switchRole(bool $asAdmin): void + { + } +} + +/** + * Fixture: Parent class with self-out for inheritance testing + * + * @template T + */ +class FixtureSelfOutParent +{ + /** + * @self-out self<'updated_state'> + */ + public function triggerUpdate(): void + { + } +} + +class FixtureSelfOutChild extends FixtureSelfOutParent +{ +} + +describe('SelfOutChecker Unit Tests', function () { + beforeEach(function () { + Config::reset(); + SelfOutChecker::reset(); + }); + + afterEach(function () { + Config::reset(); + SelfOutChecker::reset(); + }); + + describe('Generic State Transitions', function () { + test('updates object generic template binding in TemplateManager after method execution', function () { + $registry = new TypeValidatorRegistry(); + $obj = new FixtureSelfOutCheckerState(); + + TemplateManager::bindInstance($obj, FixtureSelfOutCheckerState::class . "<'unauthenticated'>"); + expect(TypePHP::getGenericType($obj))->toBe("'unauthenticated'"); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutCheckerState::class . '::login', + $obj, + [], + $registry + ); + + expect(TypePHP::getGenericType($obj))->toBe("'authenticated'"); + }); + + test('transitions generic state back upon executing logout method', function () { + $registry = new TypeValidatorRegistry(); + $obj = new FixtureSelfOutCheckerState(); + + TemplateManager::bindInstance($obj, FixtureSelfOutCheckerState::class . "<'authenticated'>"); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutCheckerState::class . '::logout', + $obj, + [], + $registry + ); + + expect(TypePHP::getGenericType($obj))->toBe("'unauthenticated'"); + }); + + test('substitutes current bound templates in dynamic self-out union accumulation', function () { + $registry = new TypeValidatorRegistry(); + $acc = new FixtureSelfOutAccumulator(); + + TemplateManager::bindInstance($acc, FixtureSelfOutAccumulator::class . '<' . Dog::class . '>'); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutAccumulator::class . '::addCat', + $acc, + [], + $registry + ); + + expect(TypePHP::getGenericType($acc))->toBe('(' . Dog::class . ' | ' . Cat::class . ')'); + }); + }); + + describe('Conditional Self-Out Contracts', function () { + test('evaluates parameter-based conditional branches in self-out', function () { + $registry = new TypeValidatorRegistry(); + $cond = new FixtureConditionalSelfOut(); + + TemplateManager::bindInstance($cond, FixtureConditionalSelfOut::class . "<'guest'>"); + + SelfOutChecker::checkSelfOut( + FixtureConditionalSelfOut::class . '::switchRole', + $cond, + ['asAdmin' => true], + $registry + ); + expect(TypePHP::getGenericType($cond))->toBe("'admin'"); + + SelfOutChecker::checkSelfOut( + FixtureConditionalSelfOut::class . '::switchRole', + $cond, + ['asAdmin' => false], + $registry + ); + expect(TypePHP::getGenericType($cond))->toBe("'guest'"); + }); + }); + + describe('Inherited Self-Out Contracts', function () { + test('resolves inherited self-out contracts on child class instances', function () { + $registry = new TypeValidatorRegistry(); + $child = new FixtureSelfOutChild(); + + TemplateManager::bindInstance($child, FixtureSelfOutChild::class . "<'initial'>"); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutParent::class . '::triggerUpdate', + $child, + [], + $registry + ); + + expect(TypePHP::getGenericType($child))->toBe("'updated_state'"); + }); + }); + + describe('Caching & Optimization', function () { + test('populates noSelfOutContractCache for methods without self-out annotations', function () { + $registry = new TypeValidatorRegistry(); + $obj = new FixtureSelfOutCheckerState(); + + $target = FixtureSelfOutCheckerState::class . '::noSelfOutMethod'; + + expect(SelfOutChecker::$noSelfOutContractCache)->not()->toHaveKey($target); + + SelfOutChecker::checkSelfOut($target, $obj, [], $registry); + + expect(SelfOutChecker::$noSelfOutContractCache)->toHaveKey($target); + }); + + test('resets noSelfOutContractCache on reset()', function () { + SelfOutChecker::$noSelfOutContractCache['Dummy::method'] = true; + expect(SelfOutChecker::$noSelfOutContractCache)->not()->toBeEmpty(); + + SelfOutChecker::reset(); + + expect(SelfOutChecker::$noSelfOutContractCache)->toBeEmpty(); + }); + }); + + describe('Configuration Toggles', function () { + test('bypasses state transitions when self_out config toggle is disabled', function () { + Config::set(['self_out' => false]); + + $registry = new TypeValidatorRegistry(); + $obj = new FixtureSelfOutCheckerState(); + + TemplateManager::bindInstance($obj, FixtureSelfOutCheckerState::class . "<'unauthenticated'>"); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutCheckerState::class . '::login', + $obj, + [], + $registry + ); + + expect(TypePHP::getGenericType($obj))->toBe("'unauthenticated'"); + }); + + test('bypasses state transitions when global master switch is disabled', function () { + Config::set(['enabled' => false]); + + $registry = new TypeValidatorRegistry(); + $obj = new FixtureSelfOutCheckerState(); + + TemplateManager::bindInstance($obj, FixtureSelfOutCheckerState::class . "<'unauthenticated'>"); + + SelfOutChecker::checkSelfOut( + FixtureSelfOutCheckerState::class . '::login', + $obj, + [], + $registry + ); + + expect(TypePHP::getGenericType($obj))->toBe("'unauthenticated'"); + }); + }); +}); diff --git a/tests/Internal/Docblock/DocblockExtractorTest.php b/tests/Internal/Docblock/DocblockExtractorTest.php new file mode 100644 index 0000000..c337460 --- /dev/null +++ b/tests/Internal/Docblock/DocblockExtractorTest.php @@ -0,0 +1,576 @@ +toBeInstanceOf(PhpDocParser::class) + ->and($lexer)->toBeInstanceOf(Lexer::class) + ; + }); + + test('getTypeParserComponents returns shared instances of TypeParser and Lexer', function () { + [$typeParser, $lexer] = DocblockExtractor::getTypeParserComponents(); + + expect($typeParser)->toBeInstanceOf(TypeParser::class) + ->and($lexer)->toBeInstanceOf(Lexer::class) + ; + }); + + test('parses raw PHPDoc comment string into PhpDocNode AST and caches the result', function () { + $doc = '/** @param positive-int $id */'; + $node1 = DocblockExtractor::parseDocString($doc); + $node2 = DocblockExtractor::parseDocString($doc); + + expect($node1)->toBeInstanceOf(PhpDocNode::class) + ->and($node1)->toBe($node2) + ->and(\count($node1->getParamTagValues()))->toBe(1) + ; + }); + + test('reset clears internal docParseCache', function () { + $doc = '/** @param string $val */'; + $nodeBefore = DocblockExtractor::parseDocString($doc); + + DocblockExtractor::reset(); + + $nodeAfter = DocblockExtractor::parseDocString($doc); + + expect($nodeBefore)->toEqual($nodeAfter) + ->and($nodeBefore)->not()->toBe($nodeAfter) + ; + }); + }); + + describe('Tag Extraction Helpers', function () { + test('extracts @template tags from docblock node', function () { + $doc = '/** @template T of \TypePHP\Tests\Fixtures\Domain\Animal */'; + $node = DocblockExtractor::parseDocString($doc); + + $templates = DocblockExtractor::extractTemplates($node); + + expect($templates)->toHaveKey('T') + ->and($templates['T']->name)->toBe('T') + ; + }); + + test('extracts property promotion type from property @var docblock', function () { + $doc = '/** @var string[] $strings */'; + $typeNode = DocblockExtractor::extractTypeFromPropertyDoc($doc, 'strings'); + + expect($typeNode)->toBeInstanceOf(TypeNode::class); + }); + + test('extracts type from constructor @param docblock on property', function () { + $doc = '/** @param non-empty-string $username */'; + $typeNode = DocblockExtractor::extractTypeFromPropertyDoc($doc, 'username'); + + expect($typeNode)->toBeInstanceOf(TypeNode::class) + ->and((string) $typeNode)->toBe('non-empty-string') + ; + }); + + test('extractTypeFromPropertyDoc returns null on unmatching property or malformed docblock', function () { + $doc = '/** @var int $otherProperty */'; + expect(DocblockExtractor::extractTypeFromPropertyDoc($doc, 'targetProperty'))->toBeNull(); + + $invalidDoc = '/* not a docblock */'; + expect(DocblockExtractor::extractTypeFromPropertyDoc($invalidDoc, 'any'))->toBeNull(); + }); + + test('extracts variable tag from doc using extractVarTagFromDoc', function () { + $namedDoc = '/** @var positive-int $count */'; + $named = DocblockExtractor::extractVarTagFromDoc($namedDoc); + + expect($named)->toBe(['positive-int', 'count']); + + $unnamedDoc = '/** @var non-empty-string */'; + $unnamed = DocblockExtractor::extractVarTagFromDoc($unnamedDoc); + + expect($unnamed)->toBe(['non-empty-string', '']); + + $noVarDoc = '/** @param int $x */'; + expect(DocblockExtractor::extractVarTagFromDoc($noVarDoc))->toBeNull(); + }); + + test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () { + $doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list \$tags\n */"; + + $scoreType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'score'); + expect((string) $scoreType)->toBe('positive-int'); + + $titleType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'title'); + expect((string) $titleType)->toBe('non-empty-string'); + + $tagsType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'tags'); + expect((string) $tagsType)->toBe('list'); + + $missingType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'missing'); + expect($missingType)->toBeNull(); + }); + + test('extracts magic method contract using extractMagicMethodContract', function () { + $doc = <<<'DOC' +/** + * @method positive-int calculateTotal(positive-int $base, non-empty-string $taxRate) + * @method bool verifyUser(string $token) + */ +DOC; + $contract = DocblockExtractor::extractMagicMethodContract($doc, 'calculateTotal'); + + expect($contract)->toBeInstanceOf(MethodTagValueNode::class) + ->and($contract->methodName)->toBe('calculateTotal') + ->and((string) $contract->returnType)->toBe('positive-int') + ->and($contract->parameters)->toHaveCount(2) + ; + + expect(DocblockExtractor::extractMagicMethodContract($doc, 'nonExistentMethod'))->toBeNull(); + }); + }); + + describe('Type Alias Extractions & Tooling Priority (@phpstan-type > @psalm-type)', function () { + test('extracts local @phpstan-type aliases', function () { + $doc = '/** @phpstan-type StatusType "active"|"pending" */'; + $node = DocblockExtractor::parseDocString($doc); + $aliases = []; + + $ref = new ReflectionClass(UserService::class); + DocblockExtractor::extractAliases($node, $aliases, $ref); + + expect($aliases)->toHaveKey('StatusType'); + }); + + test('extracts standalone @psalm-type when @phpstan-type is absent', function () { + $doc = "/** @psalm-type RoleType 'admin'|'editor' */"; + $node = DocblockExtractor::parseDocString($doc); + $aliases = []; + + $ref = new ReflectionClass(UserService::class); + DocblockExtractor::extractAliases($node, $aliases, $ref); + + expect($aliases)->toHaveKey('RoleType') + ->and((string) $aliases['RoleType'])->toBe("('admin' | 'editor')") + ; + }); + + test('prioritizes @phpstan-type over @psalm-type when both are defined', function () { + $doc = <<<'DOC' +/** + * @psalm-type RoleType 'admin'|'editor' + * @phpstan-type RoleType 'admin'|'editor'|'viewer' + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $aliases = []; + + $ref = new ReflectionClass(UserService::class); + DocblockExtractor::extractAliases($node, $aliases, $ref); + + expect($aliases)->toHaveKey('RoleType') + ->and((string) $aliases['RoleType'])->toBe("('admin' | 'editor' | 'viewer')") + ; + }); + + test('resolves imported type aliases with @phpstan-import-type', function () { + $doc = '/** @phpstan-import-type SharedShape from GlobalTypes as LocalUserShape */'; + $node = DocblockExtractor::parseDocString($doc); + $aliases = []; + + $ref = new ReflectionClass(UserApi::class); + DocblockExtractor::extractAliases($node, $aliases, $ref); + + expect($aliases)->toHaveKey('LocalUserShape'); + }); + + test('resolves imported type aliases with @psalm-import-type when phpstan tag is absent', function () { + $doc = '/** @psalm-import-type SharedShape from GlobalTypes as LocalUserShape */'; + $node = DocblockExtractor::parseDocString($doc); + $aliases = []; + + $ref = new ReflectionClass(UserApi::class); + DocblockExtractor::extractAliases($node, $aliases, $ref); + + expect($aliases)->toHaveKey('LocalUserShape') + ->and((string) $aliases['LocalUserShape'])->toContain('positive-int') + ; + }); + + test('resolves imported type aliases from Enums', function () { + $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues'); + + expect($resolvedNode)->not()->toBeNull() + ->and((string) $resolvedNode)->toContain('histogram') + ; + }); + + test('resolveImportedTypeAlias returns null on non-existent class or invalid alias', function () { + expect(DocblockExtractor::resolveImportedTypeAlias('NonExistentClass123', 'SomeAlias'))->toBeNull(); + expect(DocblockExtractor::resolveImportedTypeAlias(UserApi::class, 'NonExistentAlias'))->toBeNull(); + }); + + test('resolves multi-tier chained imported type aliases (A -> B -> C)', function () { + $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(NestedAliasChainedB::class, 'MidShape'); + + expect($resolvedNode)->not()->toBeNull() + ->and((string) $resolvedNode)->toContain('positive-int') + ->and((string) $resolvedNode)->toContain('non-empty-string') + ; + }); + + test('fully expands nested alias dependencies when extracting aliases from a class', function () { + $ref = new ReflectionClass(NestedAliasService::class); + $doc = $ref->getDocComment(); + expect($doc)->not()->toBeFalse(); + + $phpDocNode = DocblockExtractor::parseDocString((string) $doc); + $aliases = []; + + DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref); + + expect($aliases)->toHaveKey('LocalRecordList') + ->and((string) $aliases['LocalRecordList'])->toContain('positive-int') + ->and((string) $aliases['LocalRecordList'])->toContain('active') + ; + }); + }); + + describe('Prioritized Tag Extractions (@phpstan-* > @psalm-* > standard)', function () { + test('prioritizes @phpstan-param over @psalm-param and @param', function () { + $doc = <<<'DOC' +/** + * @param mixed $element + * @psalm-param int $element + * @phpstan-param positive-int $element + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $paramTags = DocblockExtractor::getParamTags($node); + + expect($paramTags)->toHaveKey('element') + ->and((string) $paramTags['element']->type)->toBe('positive-int') + ; + }); + + test('prioritizes @psalm-param over @param when @phpstan-param is absent', function () { + $doc = <<<'DOC' +/** + * @param mixed $element + * @psalm-param non-empty-string $element + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $paramTags = DocblockExtractor::getParamTags($node); + + expect($paramTags)->toHaveKey('element') + ->and((string) $paramTags['element']->type)->toBe('non-empty-string') + ; + }); + + test('prioritizes @phpstan-return over @psalm-return and @return', function () { + $doc = <<<'DOC' +/** + * @return mixed + * @psalm-return array + * @phpstan-return list + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $returnTag = DocblockExtractor::getReturnTag($node); + + expect($returnTag)->not()->toBeNull() + ->and((string) $returnTag->type)->toBe('list') + ; + }); + + test('prioritizes @psalm-return over @return when @phpstan-return is absent', function () { + $doc = <<<'DOC' +/** + * @return mixed + * @psalm-return array{id: positive-int} + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $returnTag = DocblockExtractor::getReturnTag($node); + + expect($returnTag)->not()->toBeNull() + ->and((string) $returnTag->type)->toBe('array{id: positive-int}') + ; + }); + + test('prioritizes @phpstan-var over @psalm-var and @var', function () { + $doc = <<<'DOC' +/** + * @var mixed $item + * @psalm-var int $item + * @phpstan-var positive-int $item + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $varTags = DocblockExtractor::getVarTags($node); + + expect($varTags)->toHaveCount(1) + ->and((string) $varTags[0]->type)->toBe('positive-int') + ; + }); + + test('prioritizes @psalm-var over @var when @phpstan-var is absent', function () { + $doc = <<<'DOC' +/** + * @var mixed $item + * @psalm-var non-empty-string $item + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $varTags = DocblockExtractor::getVarTags($node); + + expect($varTags)->toHaveCount(1) + ->and((string) $varTags[0]->type)->toBe('non-empty-string') + ; + }); + }); + + describe('@self-out and @this-out Extractions & Priority', function () { + test('extracts basic @self-out tag', function () { + $doc = "/** @self-out self<'authenticated'> */"; + $node = DocblockExtractor::parseDocString($doc); + $type = DocblockExtractor::getSelfOutTag($node); + + expect($type)->not()->toBeNull() + ->and((string) $type)->toContain("'authenticated'") + ; + }); + + test('extracts basic @this-out tag', function () { + $doc = "/** @this-out self<'ready'> */"; + $node = DocblockExtractor::parseDocString($doc); + $type = DocblockExtractor::getSelfOutTag($node); + + expect($type)->not()->toBeNull() + ->and((string) $type)->toContain("'ready'") + ; + }); + + test('prioritizes @phpstan-self-out over @psalm-self-out and @self-out', function () { + $doc = <<<'DOC' +/** + * @self-out self<'standard_state'> + * @psalm-self-out self<'psalm_state'> + * @phpstan-self-out self<'phpstan_state'> + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $type = DocblockExtractor::getSelfOutTag($node); + + expect($type)->not()->toBeNull() + ->and((string) $type)->toContain("'phpstan_state'") + ; + }); + + test('prioritizes @psalm-self-out over @self-out when @phpstan-self-out is absent', function () { + $doc = <<<'DOC' +/** + * @self-out self<'standard_state'> + * @psalm-self-out self<'psalm_state'> + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $type = DocblockExtractor::getSelfOutTag($node); + + expect($type)->not()->toBeNull() + ->and((string) $type)->toContain("'psalm_state'") + ; + }); + + test('getSelfOutTag returns null when no self-out or this-out tag is present', function () { + $doc = '/** @param int $val */'; + $node = DocblockExtractor::parseDocString($doc); + + expect(DocblockExtractor::getSelfOutTag($node))->toBeNull(); + }); + }); + + describe('@template Priority and Variance Extractions', function () { + test('prioritizes @phpstan-template with bound over basic @template', function () { + $doc = <<<'DOC' +/** + * @template T + * @phpstan-template T of \TypePHP\Tests\Fixtures\Domain\Animal + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $templates = DocblockExtractor::extractTemplates($node); + + expect($templates)->toHaveKey('T') + ->and($templates['T']->bound)->not()->toBeNull() + ->and((string) $templates['T']->bound)->toBe('\TypePHP\Tests\Fixtures\Domain\Animal') + ; + }); + + test('extracts declared template variances with @phpstan-template-covariant priority', function () { + $doc = <<<'DOC' +/** + * @template T + * @phpstan-template-covariant T + * @psalm-template-contravariant K + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $variances = DocblockExtractor::extractTemplateVariances($node); + + expect($variances)->toBe([ + 'T' => 'covariant', + 'K' => 'contravariant', + ]); + }); + + test('extracts all inherited template tag variations via getInheritedTags', function () { + $doc = <<<'DOC' +/** + * @template-extends BaseRepository + * @phpstan-implements ProcessorInterface + * @use LoggerTrait + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $inherited = DocblockExtractor::getInheritedTags($node); + + expect($inherited)->toHaveCount(3); + }); + + test('resolves imported type aliases defined inside stub files', function () { + $tempDir = sys_get_temp_dir() . '/typephp_doc_stub_' . uniqid(); + mkdir($tempDir, 0777, true); + + $stubPath = $tempDir . '/HelperService.stub'; + $stubContent = <<<'PHP' + [ + str_replace('\\', '/', $tempDir) . '/**', + ], + ]); + + $resolved = DocblockExtractor::resolveImportedTypeAlias(HelperService::class, 'StubbedUserShape'); + + expect($resolved)->not()->toBeNull() + ->and((string) $resolved)->toContain('positive-int') + ->and((string) $resolved)->toContain('non-empty-string') + ; + } finally { + if (file_exists($stubPath)) { + @unlink($stubPath); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + Config::reset(); + } + }); + + test('preserves multiple variable @var tags when mixed with @phpstan-var and @psalm-var', function () { + $doc = <<<'DOC' +/** + * @phpstan-var positive-int $id + * @var non-empty-string $username + * @psalm-var 'admin'|'user' $role + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $varTags = DocblockExtractor::getVarTags($node); + + expect($varTags)->toHaveCount(3); + + $tagsByName = []; + foreach ($varTags as $tag) { + $tagsByName[ltrim($tag->variableName, '$')] = (string) $tag->type; + } + + expect($tagsByName)->toHaveKey('id') + ->and($tagsByName['id'])->toBe('positive-int') + ->and($tagsByName)->toHaveKey('username') + ->and($tagsByName['username'])->toBe('non-empty-string') + ->and($tagsByName)->toHaveKey('role') + ->and($tagsByName['role'])->toBe("('admin' | 'user')") + ; + }); + }); + + describe('@param-out Tag Extractions (@param-out, @phpstan-param-out, @psalm-param-out)', function () { + test('extracts basic @param-out tag', function () { + $doc = '/** @param-out positive-int $id */'; + $node = DocblockExtractor::parseDocString($doc); + $tags = DocblockExtractor::getParamOutTags($node); + + expect($tags)->toHaveKey('id') + ->and((string) $tags['id']->type)->toBe('positive-int') + ; + }); + + test('prioritizes @phpstan-param-out over @psalm-param-out and @param-out', function () { + $doc = <<<'DOC' +/** + * @param-out mixed $id + * @psalm-param-out int $id + * @phpstan-param-out positive-int $id + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $tags = DocblockExtractor::getParamOutTags($node); + + expect($tags)->toHaveKey('id') + ->and((string) $tags['id']->type)->toBe('positive-int') + ; + }); + + test('prioritizes @psalm-param-out over @param-out when @phpstan-param-out is absent', function () { + $doc = <<<'DOC' +/** + * @param-out mixed $id + * @psalm-param-out non-empty-string $id + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $tags = DocblockExtractor::getParamOutTags($node); + + expect($tags)->toHaveKey('id') + ->and((string) $tags['id']->type)->toBe('non-empty-string') + ; + }); + }); +}); diff --git a/tests/Internal/Docblock/DocblockNormalizerTest.php b/tests/Internal/Docblock/DocblockNormalizerTest.php index 5fee685..45bafa6 100644 --- a/tests/Internal/Docblock/DocblockNormalizerTest.php +++ b/tests/Internal/Docblock/DocblockNormalizerTest.php @@ -135,6 +135,50 @@ }); }); + describe('@self-out and @this-out Tag Normalization', function () { + test('normalizes @self-out to @phpstan-self-out for native parser compatibility', function () { + $doc = "/**\n * @self-out self<'authenticated'>\n */"; + $expected = "/**\n * @phpstan-self-out self<'authenticated'>\n */"; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes @this-out to @phpstan-this-out for native parser compatibility', function () { + $doc = "/**\n * @this-out self<'configured'>\n */"; + $expected = "/**\n * @phpstan-this-out self<'configured'>\n */"; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes conditional type syntax inside @self-out', function () { + $doc = '/** @self-out ($asAdmin is true ? self<\'admin\'> : self<\'guest\'>) */'; + $expected = '/** @phpstan-self-out ($asAdmin is true ? self<\'admin\'> : self<\'guest\'>) */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('preserves priority when @phpstan-self-out is already present alongside @self-out', function () { + $doc = <<<'DOC' +/** + * @self-out self<'standard_state'> + * @psalm-self-out self<'psalm_state'> + * @phpstan-self-out self<'phpstan_state'> + */ +DOC; + expect(DocblockNormalizer::normalize($doc))->toBe($doc); + }); + + test('preserves priority when @phpstan-this-out is already present alongside @this-out', function () { + $doc = <<<'DOC' +/** + * @this-out self<'standard_state'> + * @phpstan-this-out self<'phpstan_state'> + */ +DOC; + expect(DocblockNormalizer::normalize($doc))->toBe($doc); + }); + }); + describe('Custom Class Shapes to Intersection Shapes', function () { test('converts stdClass shapes into intersection shapes', function () { $doc = '/** @param stdClass{id: int, name: string} $data */'; diff --git a/tests/Internal/Docblock/ContractParserTest.php b/tests/Internal/Docblock/DocblockParserTest.php similarity index 100% rename from tests/Internal/Docblock/ContractParserTest.php rename to tests/Internal/Docblock/DocblockParserTest.php From 739263731b863427a78cac745c4020781a4c0b37 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 03:41:54 +0800 Subject: [PATCH 05/10] Add wildcard generics support and treating mixed type as able to satisfy any upper bounds --- src/Internal/Generics/TemplateManager.php | 22 +- .../Generics/WildcardTemplateArgumentTest.php | 232 ++++++++++++++++++ 2 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 tests/TypeChecking/Generics/WildcardTemplateArgumentTest.php diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 67aa3a7..dc1a76b 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -683,12 +683,16 @@ private static function bindSingleTemplateArgument( } } - if ($templateTag->bound !== null) { + $isWildcardOrMixed = ($expectedTypeNode instanceof IdentifierTypeNode && ($expectedTypeNode->name === '*' || strtolower($expectedTypeNode->name) === 'mixed')); + + if ($templateTag->bound !== null && ! $isWildcardOrMixed) { $satisfiesBound = self::checkVariance($expectedTypeNode, $templateTag->bound, GenericTypeNode::VARIANCE_COVARIANT); if (! $satisfiesBound) { + $contextPrefix = $context !== '' ? (str_ends_with($context, ':') ? $context . ' ' : $context . ': ') : ' '; + return ErrorFactory::createError( - ($context !== '' ? $context . ': ' : '') . "Generic type argument {$expectedTypeNode} does not satisfy upper bound {$templateTag->bound} of template {$templateTag->name} in {$className}" + $contextPrefix . "Generic type argument {$expectedTypeNode} does not satisfy upper bound {$templateTag->bound} of template {$templateTag->name} in {$className}" ); } } @@ -755,15 +759,17 @@ private static function bindSingleTemplateArgument( } } + $contextPrefix = $context !== '' ? (str_ends_with($context, ':') ? $context . ' ' : $context . ' ') : ' '; + return ErrorFactory::createError( - $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" + $contextPrefix . "expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" ); } } if ($forceBind || ! isset($existingBindings[$templateName])) { $bindings = self::$instanceTemplateBindings[$instance] ?? []; - $bindings[$templateName] = $expectedTypeNode; + $bindings[$templateName] = $isWildcardOrMixed && $templateTag->bound !== null ? $templateTag->bound : $expectedTypeNode; self::$instanceTemplateBindings[$instance] = $bindings; } @@ -943,7 +949,7 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str $existingStr = (string) $existing; $expectedStr = (string) $expected; - if ($existingStr === $expectedStr || $variance === GenericTypeNode::VARIANCE_BIVARIANT || $expectedStr === 'mixed') { + if ($existingStr === $expectedStr || $variance === GenericTypeNode::VARIANCE_BIVARIANT || $expectedStr === 'mixed' || $expectedStr === '*') { return true; } @@ -1287,7 +1293,7 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof GenericTypeNode) { $base = new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->type->name, $ref)); - $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } @@ -1298,10 +1304,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new NullableTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } if ($n instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } return $n; diff --git a/tests/TypeChecking/Generics/WildcardTemplateArgumentTest.php b/tests/TypeChecking/Generics/WildcardTemplateArgumentTest.php new file mode 100644 index 0000000..691ea8e --- /dev/null +++ b/tests/TypeChecking/Generics/WildcardTemplateArgumentTest.php @@ -0,0 +1,232 @@ + + */ +class WildcardOrderEntityId implements WildcardEntityIdInterface +{ + public function getValue(): string + { + return 'order-1'; + } +} + +/** + * Circular / Self-Referential Generic Interfaces + * + * @template TId of WildcardCircularIdInterface<*> + */ +interface WildcardCircularEntityInterface +{ +} + +/** + * @template TEntity of WildcardCircularEntityInterface<*> + */ +interface WildcardCircularIdInterface +{ + public function getValue(): string; +} + +class WildcardCircularOrder implements WildcardCircularEntityInterface +{ +} + +/** + * @implements WildcardCircularIdInterface + */ +class WildcardCircularOrderId implements WildcardCircularIdInterface +{ + public function getValue(): string + { + return 'ord-123'; + } +} + +/** + * Multi-template generic class + * + * @template K of array-key + * @template V of WildcardEntityInterface + */ +class WildcardDictionary +{ + /** + * @var array + */ + public array $items = []; + + /** + * @param K $key + * @param V $val + */ + public function put(mixed $key, mixed $val): void + { + $this->items[$key] = $val; + } +} + +class WildcardProbeService +{ + /** + * Single wildcard parameter: EntityIdInterface<*> + * + * @param WildcardEntityIdInterface<*> $id + */ + public function processWildcardId(WildcardEntityIdInterface $id): string + { + return $id->getValue(); + } + + /** + * Self-referential circular wildcard parameter + * + * @param WildcardCircularIdInterface<*> $id + */ + public function processCircularWildcardId(WildcardCircularIdInterface $id): string + { + return $id->getValue(); + } + + /** + * Multi-template wildcard parameter: Dictionary<*, *> + * + * @param WildcardDictionary<*, *> $dict + */ + public function processAnyDictionary(WildcardDictionary $dict): int + { + return \count($dict->items); + } + + /** + * Partial wildcard parameter: Dictionary + * + * @param WildcardDictionary $dict + */ + public function processStringKeyDictionary(WildcardDictionary $dict): int + { + return \count($dict->items); + } + + /** + * Partial wildcard parameter: Dictionary + * + * @param WildcardDictionary $dict + */ + public function processIntKeyDictionary(WildcardDictionary $dict): int + { + return \count($dict->items); + } + + /** + * Wildcard return type + * + * @return WildcardEntityIdInterface<*> + */ + public function getWildcardId(): WildcardEntityIdInterface + { + return new WildcardOrderEntityId(); + } +} + +describe('Wildcard Generic Template Arguments (Class<*>)', function () { + describe('1. Single-Template Wildcard Parameters (EntityIdInterface<*>)', function () { + test('accepts concrete instance satisfying template upper bound when parameter specifies <*>', function () { + $probe = new WildcardProbeService(); + $orderId = new WildcardOrderEntityId(); + + $result = $probe->processWildcardId($orderId); + + expect($result)->toBe('order-1'); + }); + + test('accepts self-referential circular generic instance when parameter specifies <*>', function () { + $probe = new WildcardProbeService(); + $circularId = new WildcardCircularOrderId(); + + $result = $probe->processCircularWildcardId($circularId); + + expect($result)->toBe('ord-123'); + }); + }); + + describe('2. Multi-Template Wildcards (Dictionary<*, *> and Dictionary)', function () { + test('accepts dictionary with any key and value type when parameter specifies <*, *>', function () { + $probe = new WildcardProbeService(); + + /** @var WildcardDictionary $dict */ + $dict = new WildcardDictionary(); + $dict->put('first', new WildcardOrderEntity()); + + expect($probe->processAnyDictionary($dict))->toBe(1); + }); + + test('accepts dictionary matching specified key type with wildcard value type ', function () { + $probe = new WildcardProbeService(); + + /** @var WildcardDictionary $dict */ + $dict = new WildcardDictionary(); + $dict->put('order_key', new WildcardOrderEntity()); + + expect($probe->processStringKeyDictionary($dict))->toBe(1); + }); + + test('rejects dictionary when concrete key type conflicts with specified key type in ', function () { + $probe = new WildcardProbeService(); + + /** @var WildcardDictionary $dict */ + $dict = new WildcardDictionary(); + $dict->put('order_key', new WildcardOrderEntity()); + + expect(fn () => $probe->processIntKeyDictionary($dict)) + ->toThrow(TypeError::class) + ; + }); + }); + + describe('3. Return Types & Inline @var with Wildcards', function () { + test('accepts concrete generic instance returned from method promising Class<*>', function () { + $probe = new WildcardProbeService(); + + $result = $probe->getWildcardId(); + + expect($result)->toBeInstanceOf(WildcardOrderEntityId::class); + }); + + test('enforces inline @var assignment with wildcard Class<*>', function () { + $orderId = new WildcardOrderEntityId(); + + /** @var WildcardEntityIdInterface<*> $id */ + $id = $orderId; + + expect($id)->toBe($orderId); + }); + }); +}); From 06c4b9ae0e400222e702fa4d6528523bfd64f380 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 03:59:15 +0800 Subject: [PATCH 06/10] Resolve Self inherited class fcqn issue in Generic template type checking --- src/Internal/Generics/TemplateManager.php | 25 ++- .../Generics/SelfInInheritedGenericsTest.php | 181 ++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/TypeChecking/Generics/SelfInInheritedGenericsTest.php diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index dc1a76b..0410389 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1289,23 +1289,46 @@ private static function isRealTypeSymbol(string $name): bool private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): TypeNode { if ($n instanceof IdentifierTypeNode) { + $lower = strtolower($n->name); + if ($lower === 'self' || $lower === 'static' || $lower === '$this') { + return new IdentifierTypeNode($ref->getName()); + } + + if ($lower === 'parent') { + $parent = $ref->getParentClass(); + + return new IdentifierTypeNode($parent !== false ? $parent->getName() : 'parent'); + } + return new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->name, $ref)); } + if ($n instanceof GenericTypeNode) { - $base = new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->type->name, $ref)); + $lower = strtolower($n->type->name); + $baseName = match ($lower) { + 'self', 'static', '$this' => $ref->getName(), + 'parent' => ($parent = $ref->getParentClass()) !== false ? $parent->getName() : 'parent', + default => SpecialTypeResolver::resolveFqcn($n->type->name, $ref), + }; + + $base = new IdentifierTypeNode($baseName); $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } + if ($n instanceof ArrayTypeNode) { return new ArrayTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } + if ($n instanceof NullableTypeNode) { return new NullableTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } + if ($n instanceof UnionTypeNode) { return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } + if ($n instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } diff --git a/tests/TypeChecking/Generics/SelfInInheritedGenericsTest.php b/tests/TypeChecking/Generics/SelfInInheritedGenericsTest.php new file mode 100644 index 0000000..8da0480 --- /dev/null +++ b/tests/TypeChecking/Generics/SelfInInheritedGenericsTest.php @@ -0,0 +1,181 @@ + (Exact User Scenario) + * + * @implements SelfBoundRouteInterface + */ +enum SelfBoundOrderRoute: string implements SelfBoundRouteInterface +{ + case OrderList = 'order_list'; + case OrderDetail = 'order_detail'; + + public function getName(): string + { + return $this->value; + } +} + +/** + * 3. Standard Class implementing Interface + * + * @implements SelfBoundRouteInterface + */ +class SelfBoundUserRoute implements SelfBoundRouteInterface +{ + public function getName(): string + { + return 'user_route'; + } +} + +/** + * 4. Generic Tree Node class extending Parent + * + * @template TNode of SelfBoundTreeNode + */ +abstract class SelfBoundTreeNode +{ + /** + * @var TNode|null + */ + public ?self $parent = null; + + /** + * @param TNode|null $parent + */ + public function setParent(?self $parent): void + { + $this->parent = $parent; + } +} + +/** + * @extends SelfBoundTreeNode + */ +class SelfBoundConcreteNode extends SelfBoundTreeNode +{ +} + +class SelfBoundUnrelatedRoute implements SelfBoundRouteInterface +{ + public function getName(): string + { + return 'unrelated'; + } +} + +class SelfBoundProbeService +{ + /** + * Accepts any route whose generic argument is a covariant RouteInterface<*> + * + * @param SelfBoundRouteInterface> $route + */ + public function executeRoute(SelfBoundRouteInterface $route): string + { + return $route->getName(); + } + + /** + * Accepts tree nodes parameterized with covariant TreeNodes + * + * @param SelfBoundTreeNode> $node + */ + public function processNode(SelfBoundTreeNode $node): bool + { + return true; + } + + /** + * Method demanding specific UserRoute generic argument + * + * @param SelfBoundRouteInterface $route + */ + public function requireUserRoute(SelfBoundRouteInterface $route): string + { + return $route->getName(); + } +} + +describe('self Resolution in Inherited Generic DocBlocks (@implements and @extends)', function () { + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('1. Reified Generic Type Inspection (TypePHP::getGenericType)', function () { + test('resolves self in @implements Interface to the declaring enum FQCN', function () { + $route = SelfBoundOrderRoute::OrderList; + + expect(TypePHP::getGenericType($route))->toBe(SelfBoundOrderRoute::class); + }); + + test('resolves self in @implements Interface to the declaring class FQCN', function () { + $userRoute = new SelfBoundUserRoute(); + + expect(TypePHP::getGenericType($userRoute))->toBe(SelfBoundUserRoute::class); + }); + + test('resolves self in @extends Parent to the declaring subclass FQCN', function () { + $node = new SelfBoundConcreteNode(); + + expect(TypePHP::getGenericType($node))->toBe(SelfBoundConcreteNode::class); + }); + }); + + describe('2. Parameter Validation with Covariant Wildcard Bounds', function () { + test('accepts enum instance implementing Interface when parameter expects Interface>', function () { + $probe = new SelfBoundProbeService(); + + $result = $probe->executeRoute(SelfBoundOrderRoute::OrderList); + + expect($result)->toBe('order_list'); + }); + + test('accepts class instance implementing Interface when parameter expects Interface>', function () { + $probe = new SelfBoundProbeService(); + + $result = $probe->executeRoute(new SelfBoundUserRoute()); + + expect($result)->toBe('user_route'); + }); + + test('accepts subclass extending Parent when parameter expects Parent>', function () { + $probe = new SelfBoundProbeService(); + + $result = $probe->processNode(new SelfBoundConcreteNode()); + + expect($result)->toBeTrue(); + }); + + test('rejects instance when generic argument does not match expected concrete class', function () { + $probe = new SelfBoundProbeService(); + + expect(fn () => $probe->requireUserRoute(SelfBoundOrderRoute::OrderList)) + ->toThrow(TypeError::class) + ; + }); + }); +}); \ No newline at end of file From e5e99dea3942cc545761dbb4f9a2a6ff58310a05 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 13:36:55 +0800 Subject: [PATCH 07/10] Implement improve trait context resolution and add tests for conflicting use-statement aliases --- src/Internal/Checker/ParamChecker.php | 4 ++ src/Internal/Docblock/DocblockParser.php | 10 +++ src/Internal/Generics/TemplateManager.php | 6 +- tests/Fixtures/Traits/Context/A/ImplA.php | 9 +++ .../Fixtures/Traits/Context/A/InterfaceA.php | 9 +++ tests/Fixtures/Traits/Context/A/TraitA.php | 31 ++++++++++ tests/Fixtures/Traits/Context/B/ImplB.php | 9 +++ .../Fixtures/Traits/Context/B/InterfaceB.php | 9 +++ tests/Fixtures/Traits/Context/B/TraitB.php | 23 +++++++ .../Fixtures/Traits/Context/MultiConsumer.php | 24 ++++++++ .../Generics/SelfInInheritedGenericsTest.php | 2 +- .../TraitContextResolutionTest.php | 61 +++++++++++++++++++ 12 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/Traits/Context/A/ImplA.php create mode 100644 tests/Fixtures/Traits/Context/A/InterfaceA.php create mode 100644 tests/Fixtures/Traits/Context/A/TraitA.php create mode 100644 tests/Fixtures/Traits/Context/B/ImplB.php create mode 100644 tests/Fixtures/Traits/Context/B/InterfaceB.php create mode 100644 tests/Fixtures/Traits/Context/B/TraitB.php create mode 100644 tests/Fixtures/Traits/Context/MultiConsumer.php create mode 100644 tests/TypeChecking/InheritanceAndAttributes/TraitContextResolutionTest.php diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index e97981a..1810912 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -369,6 +369,10 @@ public static function resolveEffectiveFunction(string $function, object|string| [$classOrTrait, $methodName] = explode('::', $function, 2); + if (trait_exists($classOrTrait)) { + return self::$effectiveFunctionCache[$cacheKey] = $function; + } + $effectiveFunction = ($actualClassName !== $classOrTrait) ? $actualClassName . '::' . $methodName : $function; diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 74ffd8f..d46aeb2 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -534,6 +534,16 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri return ['doc' => $stubDoc, 'declaringClass' => $current]; } + foreach ($current->getTraits() as $trait) { + if ($trait->hasProperty($propertyName)) { + $traitProp = $trait->getProperty($propertyName); + $doc = $traitProp->getDocComment(); + if ($doc !== false) { + return ['doc' => $doc, 'declaringClass' => $trait]; + } + } + } + if ($current->hasProperty($propertyName)) { $refProp = $current->getProperty($propertyName); $doc = $refProp->getDocComment(); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 0410389..3e506c2 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1312,7 +1312,7 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): }; $base = new IdentifierTypeNode($baseName); - $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } @@ -1326,11 +1326,11 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new UnionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } return $n; diff --git a/tests/Fixtures/Traits/Context/A/ImplA.php b/tests/Fixtures/Traits/Context/A/ImplA.php new file mode 100644 index 0000000..ee5e35c --- /dev/null +++ b/tests/Fixtures/Traits/Context/A/ImplA.php @@ -0,0 +1,9 @@ +traitAProp = Suit::Hearts; + expect($consumer->traitAProp)->toBe(Suit::Hearts); + + expect(fn () => $consumer->traitAProp = StatusEnum::Active) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Enums\Suit') + ; + + $consumer->traitBProp = StatusEnum::Active; + expect($consumer->traitBProp)->toBe(StatusEnum::Active); + + expect(fn () => $consumer->traitBProp = Suit::Hearts) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Types\StatusEnum') + ; + }); + + test('resolves same-namespace typehints without explicit imports in traits', function () { + $consumer = new MultiConsumer(); + + $implA = new ImplA(); + expect($consumer->processA($implA))->toBe(ImplA::class); + + expect(fn () => $consumer->processA(new stdClass())) + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Traits\Context\A\InterfaceA') + ; + + $implB = new ImplB(); + expect($consumer->processB($implB))->toBe(ImplB::class); + }); + + test('resolves type contracts on static properties defined in traits', function () { + MultiConsumer::$traitAStaticProp = Suit::Diamonds; + expect(MultiConsumer::$traitAStaticProp)->toBe(Suit::Diamonds); + + expect(fn () => MultiConsumer::$traitAStaticProp = 'invalid') + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Enums\Suit') + ; + }); + + test('resolves return types on magic methods (@method) declared in traits', function () { + $consumer = new MultiConsumer(); + + $suit = $consumer->getMagicSuit(); + + expect($suit)->toBe(Suit::Spades); + }); +}); From c703fcac570eb6d227e13cd507fddb9171bbc6b0 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 16:07:38 +0800 Subject: [PATCH 08/10] Fix bug on A method-level @template T collides with a class-level @template T of the consuming class --- src/Internal/Checker/ParamChecker.php | 16 +- src/Internal/Generics/TemplateManager.php | 72 ++++++++- src/Internal/Resolver/SpecialTypeResolver.php | 29 ++-- src/Internal/RuntimeTypeChecker.php | 5 +- ...thodTemplateShadowingClassTemplateTest.php | 144 ++++++++++++++++++ 5 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 tests/TypeChecking/Generics/MethodTemplateShadowingClassTemplateTest.php diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 1810912..de36847 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -146,7 +146,6 @@ public static function checkParams( return null; } - // Use pre-resolved contract or parse $contract ??= DocblockParser::parse($effectiveFunction); if (! $contract['hasParamContract']) { @@ -369,10 +368,6 @@ public static function resolveEffectiveFunction(string $function, object|string| [$classOrTrait, $methodName] = explode('::', $function, 2); - if (trait_exists($classOrTrait)) { - return self::$effectiveFunctionCache[$cacheKey] = $function; - } - $effectiveFunction = ($actualClassName !== $classOrTrait) ? $actualClassName . '::' . $methodName : $function; @@ -391,7 +386,7 @@ public static function resolveEffectiveFunction(string $function, object|string| } if ($isTargetOfAlias) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6); foreach ($trace as $frame) { $frameFunc = $frame['function']; $frameClass = $frame['class'] ?? ''; @@ -511,7 +506,7 @@ private static function inferTemplatesFromClosures( foreach ($cTypeNode->parameters as $idx => $pNode) { if ($pNode->type instanceof IdentifierTypeNode && isset($templates[$pNode->type->name]) && isset($closureParams[$idx])) { $tName = $pNode->type->name; - $isClassLevel = isset($classTemplates[$tName]); + $isClassLevel = ! TemplateManager::isMethodTemplate($effectiveFunction, $tName) && isset($classTemplates[$tName]); $targetObj = $isClassLevel ? $thisObj : null; $inferredCandidate = self::extractTypeFromClosureParameter($closureParams[$idx]); @@ -643,7 +638,6 @@ private static function inferArrayTemplatesFromAllElements( } $sampleItems = self::getSampleArraySlice($arrVal); - $genericCount = \count($typeNode->genericTypes); if ($genericCount === 1 && $typeNode->genericTypes[0] instanceof IdentifierTypeNode) { @@ -741,7 +735,7 @@ private static function bindTemplateIfUnbound( ): void { $contract = DocblockParser::parse($effectiveFunction); $classTemplates = $contract['classTemplates'] ?? []; - $isClassLevelTemplate = isset($classTemplates[$templateName]); + $isClassLevelTemplate = ! TemplateManager::isMethodTemplate($effectiveFunction, $templateName) && isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; if (isset($templates[$templateName]) && ! TemplateManager::isBound($effectiveFunction, $targetObj, $templateName)) { @@ -945,7 +939,7 @@ private static function resolveClassStringTemplate( $innerType = $typeNode->genericTypes[0]; $templateName = $innerType->name; $templateNode = $templates[$templateName]; - $isClassLevelTemplate = isset($classTemplates[$templateName]); + $isClassLevelTemplate = ! TemplateManager::isMethodTemplate($function, $templateName) && isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; if (! TemplateManager::isBound($function, $targetObj, $templateName)) { @@ -1071,7 +1065,7 @@ private static function resolveTemplateParam( $templateNode = $templates[$templateName]; $isVariadic = $typeNode instanceof ArrayTypeNode; $isNullable = ($typeNode instanceof NullableTypeNode) || ($typeNode instanceof UnionTypeNode && self::typeContainsNull($typeNode)); - $isClassLevelTemplate = isset($classTemplates[$templateName]); + $isClassLevelTemplate = ! TemplateManager::isMethodTemplate($function, $templateName) && isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; $allowsNullInBound = ($templateNode->bound !== null && self::typeContainsNull($templateNode->bound)); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 3e506c2..a41de6c 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -17,6 +17,7 @@ use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockExtractor; +use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\ClassNameValidator; @@ -348,7 +349,21 @@ final class TemplateManager public static array $classNodeCache = []; /** - * Resets all static generic template bindings and call stack frames. + * In-memory cache for declared method templates per function. + * + * @var array> + */ + private static array $methodTemplatesCache = []; + + /** + * In-memory cache for isMethodTemplate checks per function and template name. + * + * @var array> + */ + private static array $isMethodTemplateCache = []; + + /** + * Resets all static generic template bindings, call stack frames, and method template caches. */ public static function reset(): void { @@ -358,6 +373,8 @@ public static function reset(): void self::$classInheritedBindingsCache = []; self::$subclassCache = []; self::$pendingCloneSource = null; + self::$methodTemplatesCache = []; + self::$isMethodTemplateCache = []; } /** @@ -450,6 +467,13 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr if (isset(self::$instanceTemplateBindings[$thisObj])) { $bindings = self::$instanceTemplateBindings[$thisObj]; } + + $methodTemplates = self::getMethodTemplates($function); + if ($methodTemplates !== []) { + foreach ($methodTemplates as $methodTName => $_) { + unset($bindings[$methodTName]); + } + } } $topFrame = self::getTopCallFrame($function); @@ -509,6 +533,42 @@ public static function getTemplateVariances(object $instance): array return []; } + /** + * Checks if a template name is declared as a method-level template on the given function/method. + */ + public static function isMethodTemplate(string $function, string $templateName): bool + { + if ($function === '' || ! str_contains($function, '::')) { + return false; + } + + if (isset(self::$isMethodTemplateCache[$function][$templateName])) { + return self::$isMethodTemplateCache[$function][$templateName]; + } + + $methodTemplates = self::getMethodTemplates($function); + + return self::$isMethodTemplateCache[$function][$templateName] = isset($methodTemplates[$templateName]); + } + + /** + * Returns declared method-level templates for the given function or method. + * + * @return array + */ + public static function getMethodTemplates(string $function): array + { + if ($function === '' || ! str_contains($function, '::')) { + return []; + } + + if (isset(self::$methodTemplatesCache[$function])) { + return self::$methodTemplatesCache[$function]; + } + + return self::$methodTemplatesCache[$function] = DocblockParser::parse($function)['templates']; + } + /** * Checks if a template name is bound in the current instance or call stack frame. */ @@ -522,6 +582,10 @@ public static function isBound(string $function, ?object $thisObj, string $templ if ($thisObj !== null) { self::ensureInstanceInherited($thisObj); + if (self::isMethodTemplate($function, $templateName)) { + return false; + } + return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); } @@ -541,6 +605,10 @@ public static function getBoundType(string $function, ?object $thisObj, string $ if ($thisObj !== null) { self::ensureInstanceInherited($thisObj); + if (self::isMethodTemplate($function, $templateName)) { + return null; + } + return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; } @@ -552,7 +620,7 @@ public static function getBoundType(string $function, ?object $thisObj, string $ */ public static function bindTemplate(string $function, ?object $thisObj, string $templateName, TypeNode $inferredType): void { - if ($thisObj !== null) { + if ($thisObj !== null && ! self::isMethodTemplate($function, $templateName)) { self::$instanceTemplateBindings ??= new WeakMap(); $bindings = self::$instanceTemplateBindings[$thisObj] ?? []; $bindings[$templateName] = $inferredType; diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index c590318..9940c9a 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -198,7 +198,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, @@ -252,11 +252,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; @@ -290,7 +290,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, @@ -344,11 +344,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; @@ -960,7 +960,7 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun $contextKey = match (true) { $ref instanceof \ReflectionClass => 'C:' . $ref->getName(), - $ref instanceof \ReflectionMethod => 'M:' . $ref->getDeclaringClass()->getName() . '::' . $ref->getName(), + $ref instanceof \ReflectionMethod => 'M:' . ($ref->getFileName() !== false ? $ref->getFileName() : $ref->getDeclaringClass()->getName()) . '::' . $ref->getName(), $ref instanceof \ReflectionFunction => 'F:' . $ref->getName(), }; @@ -970,11 +970,16 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun } $imports = self::getUseImports($ref); - $namespace = match (true) { - $ref instanceof \ReflectionClass => $ref->getNamespaceName(), - $ref instanceof \ReflectionMethod => $ref->getDeclaringClass()->getNamespaceName(), - $ref instanceof \ReflectionFunction => $ref->getNamespaceName(), - }; + $fileName = $ref->getFileName(); + $fileNamespace = ($fileName !== false && $fileName !== '') ? self::getNamespaceFromFile($fileName) : ''; + + $namespace = ($fileNamespace !== '') + ? $fileNamespace + : match (true) { + $ref instanceof \ReflectionClass => $ref->getNamespaceName(), + $ref instanceof \ReflectionMethod => $ref->getDeclaringClass()->getNamespaceName(), + $ref instanceof \ReflectionFunction => $ref->getNamespaceName(), + }; $resolved = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index cbc266c..0879bda 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -31,9 +31,6 @@ final class RuntimeTypeChecker private static ?TypeValidatorRegistry $registry = null; /** - * Cache for whether a method's return type uses method-level templates. - * MUST be public so injected AST code can read it for call-site cache bypass. - * * @var array */ public static array $hasMethodTemplatesCache = []; @@ -185,7 +182,7 @@ public static function setupScope(string $function, array $vars, object|string|n $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null; if ($hasMethodTemplates === null) { $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] = ( - $contract['returnUsesMethodTemplates'] ?? false + (($contract['templates'] ?? []) !== []) || ($contract['returnUsesMethodTemplates'] ?? false) ); self::$hasMethodTemplatesCache[$function] = $hasMethodTemplates; } diff --git a/tests/TypeChecking/Generics/MethodTemplateShadowingClassTemplateTest.php b/tests/TypeChecking/Generics/MethodTemplateShadowingClassTemplateTest.php new file mode 100644 index 0000000..6687ae5 --- /dev/null +++ b/tests/TypeChecking/Generics/MethodTemplateShadowingClassTemplateTest.php @@ -0,0 +1,144 @@ +|T $objectOrClass + * + * @return class-string|T|null + */ + public function getCachedReflectionClass(null|string|object $objectOrClass = null): null|string|object + { + return $objectOrClass; + } + + /** + * Same shadowing test when parameter uses `object` alongside class-string + * + * @template T of object + * + * @param null|class-string|object $objectOrClass + * + * @return class-string|object|null + */ + public function getSingleClassReflectionAttribute(null|string|object $objectOrClass = null): null|string|object + { + return $this->getCachedReflectionClass($objectOrClass); + } +} + +/** + * Concrete repository binding class-level T to a union of entities + * + * @extends DynamoRepositoryFixture + */ +class EventLogRepositoryFixture extends DynamoRepositoryFixture +{ + use HasReflectionClassHelperFixture; + + public function testForwardThis(): mixed + { + return $this->getCachedReflectionClass($this); + } + + public function testForwardThisViaAttributeHelper(): mixed + { + return $this->getSingleClassReflectionAttribute($this); + } +} + +/** + * Direct class (without traits) where method template T shadows class template T + * + * @template T of object + */ +class DirectClassTemplateShadowFixture +{ + /** + * @template T of object + * + * @param T $item + * + * @return T + */ + public function inspectItem(object $item): object + { + return $item; + } +} + +describe('Method-Level @template T Shadowing Class-Level @template T', function () { + test('method-level template T in trait shadows class-level template T when passing $this', function () { + $repo = new EventLogRepositoryFixture(); + + $result = $repo->testForwardThis(); + + expect($result)->toBe($repo); + }); + + test('method-level template T handles calling with arbitrary entity class-string or instances', function () { + $repo = new EventLogRepositoryFixture(); + + $entityClass = OtherEntityFixture::class; + $resultClass = $repo->getCachedReflectionClass($entityClass); + expect($resultClass)->toBe($entityClass); + + $otherInstance = new OtherEntityFixture(); + $resultInstance = $repo->getCachedReflectionClass($otherInstance); + expect($resultInstance)->toBe($otherInstance); + }); + + test('method-level template T in trait forwards $this through attribute helper', function () { + $repo = new EventLogRepositoryFixture(); + + $result = $repo->testForwardThisViaAttributeHelper(); + + expect($result)->toBe($repo); + }); + + test('direct class method template T shadows class-level template T', function () { + /** @var DirectClassTemplateShadowFixture $fixture */ + $fixture = new DirectClassTemplateShadowFixture(); + + $other = new OtherEntityFixture(); + $result = $fixture->inspectItem($other); + + expect($result)->toBe($other); + }); +}); From 9a265be613e9c894073e36018411e2a0e5f4d327 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 16:30:48 +0800 Subject: [PATCH 09/10] Fix inline @var T $object is not bound from a class-string parameter --- src/Internal/Checker/InlineChecker.php | 22 +- src/Internal/Docblock/DocblockParser.php | 2 + src/Internal/Resolver/SpecialTypeResolver.php | 12 +- ...odTemplateInlineVarFromClassStringTest.php | 238 ++++++++++++++++++ 4 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 tests/TypeChecking/Generics/MethodTemplateInlineVarFromClassStringTest.php diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 171f6e7..c8a87e4 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -346,8 +346,15 @@ private static function resolveClassContext( return $typeNode; } + $targetFunc = ($methodName !== '{closure}' && $methodName !== null && ! str_starts_with($methodName, '{closure')) + ? $className . '::' . $methodName + : $className . '::__construct'; + + $contract = DocblockParser::parse($targetFunc); + $hasMethodTemplates = ($contract['templates'] ?? []) !== []; + $cacheKey = null; - if ($thisObj === null) { + if ($thisObj === null && ! $hasMethodTemplates) { $cacheKey = ((string) $typeNode) . '|' . $className . '|' . ($methodName ?? ''); if (isset(self::$resolvedClassContextCache[$cacheKey])) { return self::$resolvedClassContextCache[$cacheKey]; @@ -360,15 +367,10 @@ private static function resolveClassContext( $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); $classAliases = DocblockParser::parseClassAliases($className); + $allTemplates = $contract['allTemplates'] ?? [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $declaredTemplates = $allTemplates; - $targetFunc = ($methodName !== '{closure}' && $methodName !== null && ! str_starts_with($methodName, '{closure')) - ? $className . '::' . $methodName - : $className . '::__construct'; - - $contract = DocblockParser::parse($targetFunc); - $declaredTemplates = $contract['allTemplates'] ?? ($contract['classTemplates'] ?? []); - - if (\count($classAliases) === 0 && \count($declaredTemplates) === 0) { + if ($classAliases === [] && $declaredTemplates === []) { if ($cacheKey !== null) { return self::$resolvedClassContextCache[$cacheKey] = $typeNode; } @@ -379,7 +381,7 @@ private static function resolveClassContext( $boundTemplates = TemplateManager::getBoundTemplates($targetFunc, $thisObj, $declaredTemplates); $activeBindings = [...$classAliases, ...$boundTemplates]; - if (\count($activeBindings) > 0 || \count($declaredTemplates) > 0) { + if ($activeBindings !== [] || $declaredTemplates !== []) { $typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $declaredTemplates); $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index d46aeb2..198c22b 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -896,6 +896,7 @@ private static function parseMethod(\ReflectionMethod $ref): array 'selfOut' => $selfOut, 'templates' => $methodTemplates, 'classTemplates' => $classTemplates, + 'allTemplates' => $allTemplates, 'return' => $returnType, 'aliases' => $aliases, 'hasParamContract' => \count($types) > 0, @@ -957,6 +958,7 @@ private static function parseFunction(\ReflectionFunction $ref): array 'selfOut' => null, 'templates' => [], 'classTemplates' => [], + 'allTemplates' => $templates, 'return' => null, 'aliases' => [], 'hasParamContract' => false, diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 9940c9a..5908569 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -198,7 +198,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, @@ -252,11 +252,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; @@ -290,7 +290,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, @@ -344,11 +344,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/tests/TypeChecking/Generics/MethodTemplateInlineVarFromClassStringTest.php b/tests/TypeChecking/Generics/MethodTemplateInlineVarFromClassStringTest.php new file mode 100644 index 0000000..18b4cfe --- /dev/null +++ b/tests/TypeChecking/Generics/MethodTemplateInlineVarFromClassStringTest.php @@ -0,0 +1,238 @@ + $class, + * + * and assigns the denormalized value under /** @var T * / + * + * @template T of object + * + * @param class-string $class + * @param array $data + * + * @return T + */ + public function denormalize(string $class, array $data): object + { + /** @var T $object */ + $object = new $class($data['name'] ?? 'default'); + + return $object; + } + + /** + * Assigns an object that violates the bound class-string + * + * @template T of object + * + * @param class-string $class + * + * @return T + */ + public function denormalizeWithViolation(string $class): object + { + /** @var T $object */ + $object = new IncompatibleTestEntity(); + + return $object; + } +} + +/** + * Static method variant + */ +class StaticSerializerFixture +{ + /** + * @template T of object + * + * @param class-string $class + * + * @return T + */ + public static function create(string $class): object + { + /** @var T $instance */ + $instance = new $class(); + + return $instance; + } +} + +/** + * Combination 1: Class-level TClass AND method-level TMethod + * + * @template TClass of object + */ +class CombinedClassAndMethodTemplateFixture +{ + /** + * @param TClass $classInstance + */ + public function __construct(public object $classInstance) + { + } + + /** + * Method with method-level template TMethod + * + * @template TMethod of object + * + * @param class-string $class + * + * @return array{class: TClass, method: TMethod} + */ + public function combine(string $class): array + { + /** @var TClass $c */ + $c = $this->classInstance; + + /** @var TMethod $m */ + $m = new $class(); + + /** @var array{class: TClass, method: TMethod} $bundle */ + $bundle = [ + 'class' => $c, + 'method' => $m, + ]; + + return $bundle; + } +} + +/** + * Combination 2: Class template T and method template T with the SAME name (shadowing) + * + * @template T of object + */ +class CombinedShadowedClassAndMethodTemplateFixture +{ + /** + * @param T $instance + */ + public function __construct(public object $instance) + { + } + + /** + * Method template T shadows class template T + * + * @template T of object + * + * @param class-string $class + * + * @return T + */ + public function createMethodInstance(string $class): object + { + /** @var T $obj */ + $obj = new $class(); + + return $obj; + } +} + +describe('Inline @var T bound from class-string parameter', function () { + test('binds method-level template T from class-string parameter for inline @var T $object', function () { + $serializer = new ConfigArraySerializerFixture(); + + $result = $serializer->denormalize(SimpleTestEntity::class, ['name' => 'Sample']); + + expect($result)->toBeInstanceOf(SimpleTestEntity::class) + ->and($result->name)->toBe('Sample') + ; + }); + + test('binds method-level template T dynamically across different calls with different classes', function () { + $serializer = new ConfigArraySerializerFixture(); + + $first = $serializer->denormalize(SimpleTestEntity::class, ['name' => 'First']); + expect($first)->toBeInstanceOf(SimpleTestEntity::class); + + $second = $serializer->denormalize(AnotherTestEntity::class, ['name' => 'Second']); + expect($second)->toBeInstanceOf(AnotherTestEntity::class); + }); + + test('binds method-level template T on static methods for inline @var T', function () { + $result = StaticSerializerFixture::create(SimpleTestEntity::class); + + expect($result)->toBeInstanceOf(SimpleTestEntity::class); + }); + + test('enforces bound template T on inline @var and rejects incompatible assignments', function () { + $serializer = new ConfigArraySerializerFixture(); + + expect(fn () => $serializer->denormalizeWithViolation(SimpleTestEntity::class)) + ->toThrow( + TypeError::class, + 'Variable $object must be of type ' . SimpleTestEntity::class . ', ' . IncompatibleTestEntity::class . ' given' + ) + ; + }); + + describe('Combination of Class-Level and Method-Level Templates for Inline @var', function () { + test('resolves both TClass and TMethod in inline @var annotations in the same method', function () { + $classObj = new ClassEntityFixture(); + $fixture = new CombinedClassAndMethodTemplateFixture($classObj); + + $result = $fixture->combine(MethodEntityFixture::class); + + expect($result['class'])->toBe($classObj) + ->and($result['method'])->toBeInstanceOf(MethodEntityFixture::class) + ; + }); + + test('method-level T shadows class-level T for inline @var while preserving class-level T outside', function () { + $classObj = new ClassEntityFixture(); + $fixture = new CombinedShadowedClassAndMethodTemplateFixture($classObj); + + $methodObj = $fixture->createMethodInstance(MethodEntityFixture::class); + + expect($methodObj)->toBeInstanceOf(MethodEntityFixture::class) + ->and($fixture->instance)->toBe($classObj) + ; + }); + }); +}); From 06eaf6714d4506c8ec9e1a86b5b98be9d18d4a56 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 17 Sep 2026 19:30:00 +0800 Subject: [PATCH 10/10] Add beforeEach and afterEach hooks to reset Config in Pest tests --- tests/Pest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Pest.php b/tests/Pest.php index 174d7fd..6d1b5f3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,3 +1,14 @@ beforeEach(function () { + Config::reset(); + }) + ->afterEach(function () { + Config::reset(); + }) + ->in('Contract', 'Internal', 'Feature'); \ No newline at end of file