From 26deb918690ac154808522b60fc060f43f95c5f3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 13:45:55 +0800 Subject: [PATCH 01/23] Add F-Bounded Polymorphism tests and implement scalar class collision handling --- src/Internal/Resolver/SpecialTypeResolver.php | 47 +++- .../Validator/IdentifierValidator.php | 9 +- .../Generics/FBoundedPolymorphismTest.php | 219 ++++++++++++++++++ .../Scalars/ScalarClassCollisionTest.php | 105 +++++++++ 4 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 tests/TypeChecking/Generics/FBoundedPolymorphismTest.php create mode 100644 tests/TypeChecking/Scalars/ScalarClassCollisionTest.php diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 556c433..8cd2509 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -110,6 +110,15 @@ final class SpecialTypeResolver 'closed-resource' => true, ]; + /** + * Scalar aliases that are not reserved keywords in PHP and can be user class names. + */ + private const OVERRIDABLE_SCALAR_ALIASES = [ + 'integer' => true, + 'boolean' => true, + 'double' => true, + ]; + /** * In-memory cache for Reflection instances per context string. * @@ -204,7 +213,9 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof IdentifierTypeNode) { $lower = strtolower($node->name); if (isset(self::BUILTIN_TYPE_KEYWORDS[$lower]) && $lower !== 'self' && $lower !== 'parent' && $lower !== 'static' && $lower !== '$this') { - return $node; + if (! isset(self::OVERRIDABLE_SCALAR_ALIASES[$lower]) || $node->name === $lower) { + return $node; + } } } @@ -236,7 +247,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, @@ -290,11 +301,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; @@ -328,7 +339,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, @@ -382,11 +393,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; @@ -1056,8 +1067,11 @@ public static function getNamespaceFromFile(string $fileName): string */ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): string { + $lower = strtolower($name); if (self::isBuiltInTypeKeyword($name)) { - return $name; + if (! isset(self::OVERRIDABLE_SCALAR_ALIASES[$lower]) || $name === $lower) { + return $name; + } } if (str_starts_with($name, '\\')) { @@ -1089,6 +1103,10 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun $resolved = self::resolveNameFromImportsAndNamespace($name, [], $namespace); + if (isset(self::OVERRIDABLE_SCALAR_ALIASES[$lower]) && $resolved === $name && ! self::symbolExists($name)) { + return self::$fqcnCache[$contextKey][$name] = $name; + } + return self::$fqcnCache[$contextKey][$name] = $resolved; } @@ -1098,9 +1116,12 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun public static function resolveFqcnForFile(string $name, string $file): string { $file = str_replace('\\', '/', $file); + $lower = strtolower($name); if (self::isBuiltInTypeKeyword($name)) { - return $name; + if (! isset(self::OVERRIDABLE_SCALAR_ALIASES[$lower]) || $name === $lower) { + return $name; + } } if (str_starts_with($name, '\\')) { @@ -1118,7 +1139,13 @@ public static function resolveFqcnForFile(string $name, string $file): string $imports = self::getUseImportsFromFile($file); $namespace = self::getNamespaceFromFile($file); - return self::$fileFqcnCache[$file][$name] = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + $resolved = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + + if (isset(self::OVERRIDABLE_SCALAR_ALIASES[$lower]) && $resolved === $name && ! self::symbolExists($name)) { + return self::$fileFqcnCache[$file][$name] = $name; + } + + return self::$fileFqcnCache[$file][$name] = $resolved; } /** diff --git a/src/Internal/Validator/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index 9dc16c9..80b8a1d 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -25,10 +25,13 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $lower = strtolower($name); $ok = match ($lower) { - 'int', 'integer' => \is_int($value), + 'int' => \is_int($value), + 'integer' => \is_int($value) || (\is_object($value) && $name !== 'integer' && $this->validateClassOrIgnore($value, $name)), 'string' => \is_string($value), - 'float', 'double' => \is_float($value) || \is_int($value), - 'bool', 'boolean' => \is_bool($value), + 'float' => \is_float($value) || \is_int($value), + 'double' => \is_float($value) || \is_int($value) || (\is_object($value) && $name !== 'double' && $this->validateClassOrIgnore($value, $name)), + 'bool' => \is_bool($value), + 'boolean' => \is_bool($value) || (\is_object($value) && $name !== 'boolean' && $this->validateClassOrIgnore($value, $name)), 'array' => \is_array($value), 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), 'object', 'self', 'static', 'parent', '$this' => \is_object($value), diff --git a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php new file mode 100644 index 0000000..7641b14 --- /dev/null +++ b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php @@ -0,0 +1,219 @@ + + */ +final class FBoundText implements FBoundComparable +{ + public function __construct(public string $value) + { + } + + public function compareTo(mixed $other): int + { + return strcmp($this->value, $other->value); + } +} + +/** + * @implements FBoundComparable + */ +final class FBoundNum implements FBoundComparable +{ + public function __construct(public int $value) + { + } + + public function compareTo(mixed $other): int + { + return $this->value <=> $other->value; + } +} + +/** + * Not Comparable at all + */ +final class FBoundPlain +{ + public function __construct(public int $v) + { + } +} + +/** + * Raw min without docblock contract + */ +function fBoundRawMin(FBoundComparable $a, FBoundComparable $b): FBoundComparable +{ + return $a->compareTo($b) <= 0 ? $a : $b; +} + +/** + * Java: > T fMin(T a, T b) + * C#: T Max(T a, T b) where T : IComparable + * + * @template T of FBoundComparable + * + * @param T $a + * @param T $b + * + * @return T + */ +function fBoundMin(FBoundComparable $a, FBoundComparable $b): FBoundComparable +{ + return $a->compareTo($b) <= 0 ? $a : $b; +} + +/** + * C#: class AGenericClass where T : IComparable + * + * @template T of FBoundComparable + */ +final class FBoundSortedList +{ + /** + * @var list + */ + public array $items = []; + + /** + * @param T $item + */ + public function add(mixed $item): void + { + $this->items[] = $item; + } + + public function count(): int + { + return \count($this->items); + } +} + +/** + * Java: abstract class Base> + * + * @template T of FBoundBase + */ +abstract class FBoundBase +{ + /** + * @return T + */ + public function copy(): static + { + return clone $this; + } +} + +/** + * @extends FBoundBase + */ +final class FBoundDerived extends FBoundBase +{ +} + +/** Claims T = FBoundDerived, but is FBoundImpostor */ +/** + * @extends FBoundBase + */ +final class FBoundImpostor extends FBoundBase +{ +} + +describe('F-Bounded Polymorphism (@template T of Interface)', function () { + describe('1. Function-Level F-Bounds (fMin)', function () { + test('fMin accepts homogeneous matching types (Text, Text)', function () { + $a = new FBoundText('apple'); + $b = new FBoundText('banana'); + + expect(fBoundMin($a, $b))->toBe($a); + }); + + test('fMin accepts homogeneous matching types (Num, Num)', function () { + $a = new FBoundNum(3); + $b = new FBoundNum(7); + + expect(fBoundMin($a, $b))->toBe($a); + }); + + test('fMin rejects heterogeneous types (Text, Num) on entry at parameter $b', function () { + expect(fn () => fBoundMin(new FBoundText('cat'), new FBoundNum(3))) + ->toThrow(TypeError::class, 'fBoundMin(): Argument $b') + ; + }); + + test('rawMin intercepts and throws on compareTo() callee boundary', function () { + expect(fn () => fBoundRawMin(new FBoundText('cat'), new FBoundNum(3))) + ->toThrow(TypeError::class, 'FBoundText::compareTo(): Argument $other') + ; + }); + }); + + describe('2. Class-Level F-Bounds (SortedList>)', function () { + test('SortedList accepts FBoundNum items', function () { + /** @var FBoundSortedList $list */ + $list = new FBoundSortedList(); + $list->add(new FBoundNum(5)); + $list->add(new FBoundNum(2)); + + expect($list->count())->toBe(2); + }); + + test('SortedList rejects FBoundText items', function () { + /** @var FBoundSortedList $list */ + $list = new FBoundSortedList(); + $list->add(new FBoundNum(5)); + + expect(fn () => $list->add(new FBoundText('x'))) + ->toThrow(TypeError::class, 'Argument $item (template T = TypePHP\Tests\TypeChecking\Generics\FBoundNum) must be of type TypePHP\Tests\TypeChecking\Generics\FBoundNum') + ; + }); + + test('SortedList rejects non-comparable class FBoundPlain on pre-binding', function () { + expect(function () { + /** @var FBoundSortedList $list */ + $list = new FBoundSortedList(); + })->toThrow(TypeError::class, 'does not satisfy upper bound'); + }); + }); + + describe('3. Self-Typed Hierarchy (Base>)', function () { + test('Derived->copy() returns Derived', function () { + $derived = new FBoundDerived(); + expect($derived->copy())->toBeInstanceOf(FBoundDerived::class); + }); + + test('Impostor->copy() violates @return T contract', function () { + $impostor = new FBoundImpostor(); + + expect(fn () => $impostor->copy()) + ->toThrow(TypeError::class, 'Return value must be of type TypePHP\Tests\TypeChecking\Generics\FBoundDerived, TypePHP\Tests\TypeChecking\Generics\FBoundImpostor returned') + ; + }); + + test('rejects candidate when class does not satisfy self-bound Base', function () { + expect(function () { + /** @var FBoundBase $base */ + $base = new FBoundDerived(); + })->toThrow(TypeError::class); + }); + }); +}); diff --git a/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php b/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php new file mode 100644 index 0000000..4002087 --- /dev/null +++ b/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php @@ -0,0 +1,105 @@ +toBe($int); + }); + + test('accepts class named Boolean matching parameter type contract @param Boolean $boolObj', function () { + $bool = new Boolean(true); + + expect(acceptBooleanObject($bool))->toBe($bool); + }); + + test('accepts class named Double matching parameter type contract @param Double $doubleObj', function () { + $double = new Double(3.14); + + expect(acceptDoubleObject($double))->toBe($double); + }); + + test('still supports legacy scalar aliases integer, boolean, double on primitive values', function () { + expect(acceptLegacyScalarAliases(42, true, 3.14))->toBe('42:1:3.14'); + + expect(fn () => acceptLegacyScalarAliases('invalid', true, 3.14)) + ->toThrow(\TypeError::class) + ; + }); + + test('rejects primitive int when class Integer is expected', function () { + expect(fn () => acceptIntegerObject(42)) + ->toThrow(TypeError::class) + ; + }); + + test('rejects unrelated class when class Integer is expected', function () { + expect(fn () => acceptIntegerObject(new Car())) + ->toThrow(TypeError::class) + ; + }); +}); \ No newline at end of file From e55904c4038a4b903dc14658925afd2fa18544df Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 13:46:11 +0800 Subject: [PATCH 02/23] Enhance F-Bounded Template Handling and Update Scalar Type Aliases in Tests --- src/Internal/Checker/ParamChecker.php | 10 +++-- src/Internal/Generics/TemplateManager.php | 42 +++++++++++++++++-- src/Internal/Resolver/SpecialTypeResolver.php | 12 +++--- .../Scalars/ScalarClassCollisionTest.php | 8 ++-- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 1d83cf7..eb965f1 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -1472,7 +1472,12 @@ private static function bindInitialTemplate( $inferredType = TemplateManager::inferTypeFromValue($sampleVal); if ($templateNode->bound !== null) { - $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + $boundNode = $templateNode->bound; + if (TemplateManager::isFBounded($templateNode, $function)) { + $boundNode = TemplateSubstitutor::substitute($boundNode, [$templateName => $inferredType]); + } + + $resolvedBound = SpecialTypeResolver::resolve($boundNode, $function, $thisObj); $inferredType = self::refineInferredTypeFromBound($sampleVal, $resolvedBound, $inferredType); $err = $registry->validate($sampleVal, $resolvedBound, $function . '(): Argument $' . $paramName . ' (template ' . $templateName . ')'); @@ -1620,12 +1625,11 @@ private static function tryWidenTemplate( ErrorMessage $originalError, TypeValidatorRegistry $registry ): ?ErrorMessage { - if ($isClassLevelTemplate || $templateNode->bound === null) { + if ($isClassLevelTemplate || $templateNode->bound === null || TemplateManager::isFBounded($templateNode, $function)) { return $originalError; } $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); - $boundErr = $registry->validate($val, $resolvedBound, $context . ' (template ' . $templateName . ')'); if ($boundErr === null) { diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index c0b7462..b953250 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -376,6 +376,14 @@ final class TemplateManager */ private static array $isMethodTemplateCache = []; + /** + * 2D Cache for whether a template is F-bounded (recursive self-bound): + * [$context][$templateName] => bool + * + * @var array> + */ + private static array $isFBoundCache = []; + /** * Stack storing pending generic instantiations for constructors. * @@ -397,6 +405,7 @@ public static function reset(): void self::$pendingCloneSource = null; self::$methodTemplatesCache = []; self::$isMethodTemplateCache = []; + self::$isFBoundCache = []; self::$pendingInstantiations = []; } @@ -546,6 +555,28 @@ public static function clearCallBindings(string $function, array $templates): vo self::pushCallFrame($function); } + /** + * Checks whether a template declaration has an F-bound (self-referential upper bound like T of Comparable). + */ + public static function isFBounded(TemplateTagValueNode $templateNode, string $context = ''): bool + { + if ($templateNode->bound === null) { + return false; + } + + $templateName = $templateNode->name; + $contextKey = $context !== '' ? $context : spl_object_hash($templateNode); + + if (isset(self::$isFBoundCache[$contextKey][$templateName])) { + return self::$isFBoundCache[$contextKey][$templateName]; + } + + return self::$isFBoundCache[$contextKey][$templateName] = DocblockParser::typeReferencesTemplate( + $templateNode->bound, + [$templateName => true] + ); + } + /** * Retrieves currently bound template types for a function call or object instance / static class. * @@ -899,10 +930,15 @@ private static function bindSingleTemplateArgument( $isWildcardOrMixed = ($expectedTypeNode instanceof IdentifierTypeNode && ($expectedTypeNode->name === '*' || strtolower($expectedTypeNode->name) === 'mixed')); if ($templateTag->bound !== null && ! $isWildcardOrMixed) { - $satisfiesBound = self::checkVariance($expectedTypeNode, $templateTag->bound, GenericTypeNode::VARIANCE_COVARIANT); + $boundNode = $templateTag->bound; + if (self::isFBounded($templateTag, $className)) { + $boundNode = TemplateSubstitutor::substitute($boundNode, [$templateTag->name => $expectedTypeNode]); + } + + $satisfiesBound = self::checkVariance($expectedTypeNode, $boundNode, GenericTypeNode::VARIANCE_COVARIANT); if (! $satisfiesBound) { - $contextPrefix = $context !== '' ? (str_ends_with($context, ':') ? $context . ' ' : $context . ': ') : ' '; + $contextPrefix = $context !== '' ? (str_ends_with($context, ':') ? $context . ' ' : $context . ' ') : ' '; return ErrorFactory::createError( $contextPrefix . "Generic type argument {$expectedTypeNode} does not satisfy upper bound {$templateTag->bound} of template {$templateTag->name} in {$className}" @@ -1303,7 +1339,7 @@ private static function checkExpectedIntersectionVariance(TypeNode $existing, In if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { foreach ($expected->types as $intersectionMember) { if (! self::checkVariance($existing, $intersectionMember, $variance)) { - return true; + return false; } } diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 8cd2509..9ce729a 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -247,7 +247,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, @@ -301,11 +301,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; @@ -339,7 +339,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, @@ -393,11 +393,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/Scalars/ScalarClassCollisionTest.php b/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php index 4002087..dfb3cfb 100644 --- a/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php +++ b/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php @@ -55,9 +55,9 @@ function acceptDoubleObject(mixed $doubleObj): mixed /** * Legacy PHPDoc using scalar aliases: * - * @param integer $scalarInt - * @param boolean $scalarBool - * @param double $scalarDouble + * @param int $scalarInt + * @param bool $scalarBool + * @param float $scalarDouble */ function acceptLegacyScalarAliases(int $scalarInt, bool $scalarBool, float $scalarDouble): string { @@ -102,4 +102,4 @@ function acceptLegacyScalarAliases(int $scalarInt, bool $scalarBool, float $scal ->toThrow(TypeError::class) ; }); -}); \ No newline at end of file +}); From c74f4dbd9caec0fae16e7ea4c6368b5a8a1e33ff Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 14:37:09 +0800 Subject: [PATCH 03/23] Refactor type node handling to replace array_map with foreach for improved and performance across internal engine --- src/Internal/Checker/ConditionalChecker.php | 38 +++++---- src/Internal/Docblock/DocblockParser.php | 34 ++++---- src/Internal/Generics/TemplateManager.php | 40 ++++++--- src/Internal/Generics/TemplateSubstitutor.php | 34 ++++---- src/Internal/Resolver/SpecialTypeResolver.php | 81 +++++++++++++------ 5 files changed, 140 insertions(+), 87 deletions(-) diff --git a/src/Internal/Checker/ConditionalChecker.php b/src/Internal/Checker/ConditionalChecker.php index d6ad145..9c98d19 100644 --- a/src/Internal/Checker/ConditionalChecker.php +++ b/src/Internal/Checker/ConditionalChecker.php @@ -145,10 +145,10 @@ public static function resolve( if ($typeNode instanceof GenericTypeNode) { $genericType = self::resolve($typeNode->type, $vars, $boundTemplates, $registry, $function); - $genericTypes = array_map( - fn ($t) => self::resolve($t, $vars, $boundTemplates, $registry, $function), - $typeNode->genericTypes - ); + $genericTypes = []; + foreach ($typeNode->genericTypes as $gt) { + $genericTypes[] = self::resolve($gt, $vars, $boundTemplates, $registry, $function); + } return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $typeNode->type, @@ -158,17 +158,21 @@ public static function resolve( } if ($typeNode instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( - fn ($t) => self::resolve($t, $vars, $boundTemplates, $registry, $function), - $typeNode->types - )); + $types = []; + foreach ($typeNode->types as $t) { + $types[] = self::resolve($t, $vars, $boundTemplates, $registry, $function); + } + + return new UnionTypeNode($types); } if ($typeNode instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( - fn ($t) => self::resolve($t, $vars, $boundTemplates, $registry, $function), - $typeNode->types - )); + $types = []; + foreach ($typeNode->types as $t) { + $types[] = self::resolve($t, $vars, $boundTemplates, $registry, $function); + } + + return new IntersectionTypeNode($types); } if ($typeNode instanceof ArrayShapeNode) { @@ -211,16 +215,16 @@ public static function resolve( } if ($typeNode instanceof CallableTypeNode) { - $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + $parameters = []; + foreach ($typeNode->parameters as $param) { + $parameters[] = new CallableTypeParameterNode( self::resolve($param->type, $vars, $boundTemplates, $registry, $function), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional - ), - $typeNode->parameters - ); + ); + } $returnType = self::resolve($typeNode->returnType, $vars, $boundTemplates, $registry, $function); diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 9c777dd..30423d5 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -1717,16 +1717,16 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo } if ($node instanceof CallableTypeNode) { - $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + $parameters = []; + foreach ($node->parameters as $param) { + $parameters[] = new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional - ), - $node->parameters - ); + ); + } $returnType = self::substituteAliases($node->returnType, $aliases); @@ -1751,10 +1751,10 @@ 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), - $node->genericTypes - ); + $genericTypes = []; + foreach ($node->genericTypes as $gt) { + $genericTypes[] = self::substituteAliases($gt, $aliases); + } return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -1768,10 +1768,10 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo } if ($node instanceof UnionTypeNode) { - $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), - $node->types - ); + $types = []; + foreach ($node->types as $t) { + $types[] = self::substituteAliases($t, $aliases); + } foreach ($types as $t) { if ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed') { @@ -1783,10 +1783,10 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo } if ($node instanceof IntersectionTypeNode) { - $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), - $node->types - ); + $types = []; + foreach ($node->types as $t) { + $types[] = self::substituteAliases($t, $aliases); + } $filtered = array_values(array_filter($types, function ($t) { return ! ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed'); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index b953250..dae0c6c 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1586,7 +1586,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): }; $base = new IdentifierTypeNode($baseName); - $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = []; + foreach ($n->genericTypes as $gt) { + $generics[] = self::resolveTypeNodeAst($gt, $ref); + } return new GenericTypeNode($base, $generics, $n->variances); } @@ -1600,21 +1603,32 @@ 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)); + $types = []; + foreach ($n->types as $t) { + $types[] = self::resolveTypeNodeAst($t, $ref); + } + + return new UnionTypeNode($types); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + $types = []; + foreach ($n->types as $t) { + $types[] = self::resolveTypeNodeAst($t, $ref); + } + + return new IntersectionTypeNode($types); } if ($n instanceof ArrayShapeNode) { - $items = array_map(function ($item) use ($ref) { - return new ArrayShapeItemNode( + $items = []; + foreach ($n->items as $item) { + $items[] = new ArrayShapeItemNode( $item->keyName, $item->optional, self::resolveTypeNodeAst($item->valueType, $ref) ); - }, $n->items); + } $unsealed = null; if ($n->unsealedType !== null) { @@ -1633,27 +1647,29 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof ObjectShapeNode) { - $items = array_map(function ($item) use ($ref) { - return new ObjectShapeItemNode( + $items = []; + foreach ($n->items as $item) { + $items[] = new ObjectShapeItemNode( $item->keyName, $item->optional, self::resolveTypeNodeAst($item->valueType, $ref) ); - }, $n->items); + } return new ObjectShapeNode($items); } if ($n instanceof CallableTypeNode) { - $params = array_map(function ($p) use ($ref) { - return new CallableTypeParameterNode( + $params = []; + foreach ($n->parameters as $p) { + $params[] = new CallableTypeParameterNode( self::resolveTypeNodeAst($p->type, $ref), $p->isReference, $p->isVariadic, $p->parameterName, $p->isOptional ); - }, $n->parameters); + } return new CallableTypeNode( $n->identifier, diff --git a/src/Internal/Generics/TemplateSubstitutor.php b/src/Internal/Generics/TemplateSubstitutor.php index be32184..a7f646f 100644 --- a/src/Internal/Generics/TemplateSubstitutor.php +++ b/src/Internal/Generics/TemplateSubstitutor.php @@ -167,19 +167,19 @@ private static function substituteNode( } if ($node instanceof UnionTypeNode) { - $types = array_map( - fn ($t) => self::substituteNode($t, $boundTemplates, $declaredTemplates, $visited), - $node->types - ); + $types = []; + foreach ($node->types as $t) { + $types[] = self::substituteNode($t, $boundTemplates, $declaredTemplates, $visited); + } return self::normalizeUnion($types); } if ($node instanceof IntersectionTypeNode) { - $types = array_map( - fn ($t) => self::substituteNode($t, $boundTemplates, $declaredTemplates, $visited), - $node->types - ); + $types = []; + foreach ($node->types as $t) { + $types[] = self::substituteNode($t, $boundTemplates, $declaredTemplates, $visited); + } return self::normalizeIntersection($types); } @@ -236,16 +236,16 @@ private static function substituteCallable( array $declaredTemplates, array $visited ): CallableTypeNode { - $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + $parameters = []; + foreach ($node->parameters as $param) { + $parameters[] = new CallableTypeParameterNode( self::substituteNode($param->type, $boundTemplates, $declaredTemplates, $visited), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional - ), - $node->parameters - ); + ); + } $returnType = self::substituteNode($node->returnType, $boundTemplates, $declaredTemplates, $visited); @@ -309,10 +309,10 @@ private static function substituteGeneric( array $visited ): GenericTypeNode { $type = self::substituteNode($node->type, $boundTemplates, $declaredTemplates, $visited); - $genericTypes = array_map( - fn ($t) => self::substituteNode($t, $boundTemplates, $declaredTemplates, $visited), - $node->genericTypes - ); + $genericTypes = []; + foreach ($node->genericTypes as $gt) { + $genericTypes[] = self::substituteNode($gt, $boundTemplates, $declaredTemplates, $visited); + } return new GenericTypeNode( $type instanceof IdentifierTypeNode ? $type : $node->type, diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 9ce729a..ba557d9 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -247,7 +247,10 @@ 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 = []; + foreach ($node->genericTypes as $gt) { + $innerTypes[] = self::resolve($gt, $context, $thisObj); + } return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -301,11 +304,21 @@ 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)); + $types = []; + foreach ($node->types as $t) { + $types[] = self::resolve($t, $context, $thisObj); + } + + return new UnionTypeNode($types); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); + $types = []; + foreach ($node->types as $t) { + $types[] = self::resolve($t, $context, $thisObj); + } + + return new IntersectionTypeNode($types); } return $node; @@ -339,7 +352,10 @@ 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 = []; + foreach ($node->genericTypes as $gt) { + $innerTypes[] = self::resolveForFile($gt, $file); + } return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -393,11 +409,21 @@ 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)); + $types = []; + foreach ($node->types as $t) { + $types[] = self::resolveForFile($t, $file); + } + + return new UnionTypeNode($types); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + $types = []; + foreach ($node->types as $t) { + $types[] = self::resolveForFile($t, $file); + } + + return new IntersectionTypeNode($types); } return clone $node; @@ -571,7 +597,9 @@ private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \Reflect private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): ArrayShapeNode { $ref = self::getReflectionContext($context); - $items = array_map(function ($item) use ($ref, $context, $thisObj) { + $items = []; + + foreach ($node->items as $item) { /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ $keyName = $item->keyName; @@ -606,12 +634,12 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass } } - return new ArrayShapeItemNode( + $items[] = new ArrayShapeItemNode( $keyName, $item->optional, self::resolve($item->valueType, $context, $thisObj) ); - }, $node->items); + } if ($node->sealed) { return ArrayShapeNode::createSealed($items, $node->kind); @@ -632,13 +660,14 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass */ private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): ObjectShapeNode { - $items = array_map(function ($item) use ($context, $thisObj) { - return new ObjectShapeItemNode( + $items = []; + foreach ($node->items as $item) { + $items[] = new ObjectShapeItemNode( $item->keyName, $item->optional, self::resolve($item->valueType, $context, $thisObj) ); - }, $node->items); + } return new ObjectShapeNode($items); } @@ -648,15 +677,16 @@ private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionCla */ private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): CallableTypeNode { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) { - return new CallableTypeParameterNode( + $resolvedParameters = []; + foreach ($node->parameters as $param) { + $resolvedParameters[] = new CallableTypeParameterNode( self::resolve($param->type, $context, $thisObj), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional ); - }, $node->parameters); + } $resolvedReturnType = self::resolve($node->returnType, $context, $thisObj); @@ -711,7 +741,8 @@ private static function resolveOffsetAccessForFile(OffsetAccessTypeNode $node, s private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $file): ArrayShapeNode { - $items = array_map(function ($item) use ($file) { + $items = []; + foreach ($node->items as $item) { /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ $keyName = $item->keyName; @@ -739,12 +770,12 @@ private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $f } } - return new ArrayShapeItemNode( + $items[] = new ArrayShapeItemNode( $keyName, $item->optional, self::resolveForFile($item->valueType, $file) ); - }, $node->items); + } if ($node->sealed) { return ArrayShapeNode::createSealed($items, $node->kind); @@ -762,28 +793,30 @@ private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $f private static function resolveObjectShapeForFile(ObjectShapeNode $node, string $file): ObjectShapeNode { - $items = array_map(function ($item) use ($file) { - return new ObjectShapeItemNode( + $items = []; + foreach ($node->items as $item) { + $items[] = new ObjectShapeItemNode( $item->keyName, $item->optional, self::resolveForFile($item->valueType, $file) ); - }, $node->items); + } return new ObjectShapeNode($items); } private static function resolveCallableForFile(CallableTypeNode $node, string $file): CallableTypeNode { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { - return new CallableTypeParameterNode( + $resolvedParameters = []; + foreach ($node->parameters as $param) { + $resolvedParameters[] = new CallableTypeParameterNode( self::resolveForFile($param->type, $file), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional ); - }, $node->parameters); + } $resolvedReturnType = self::resolveForFile($node->returnType, $file); From 867fb31a7eaae2f054bbb8e0400cde7b2da31161 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 17:26:06 +0800 Subject: [PATCH 04/23] Refactor type handling to use iterative approaches for NullableType and ArrayType across multiple classes instead of recursion for optimizing performance --- src/Internal/Ast/ContractVisitor.php | 10 ++-- src/Internal/Ast/FunctionContractInjector.php | 4 ++ src/Internal/Checker/ConditionalChecker.php | 8 ++-- src/Internal/Checker/InlineChecker.php | 24 +++++----- src/Internal/Docblock/DocblockExtractor.php | 41 +++++++++++------ src/Internal/Docblock/DocblockParser.php | 23 +++++++--- src/Internal/Generics/TemplateSubstitutor.php | 14 ++++-- src/Internal/Resolver/HierarchyResolver.php | 46 +++++++++++-------- src/Internal/Validator/NullableValidator.php | 7 ++- 9 files changed, 115 insertions(+), 62 deletions(-) diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 48718ac..be28b84 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -216,10 +216,14 @@ public function leaveNode(Node $node): ?Node private function markWriteContext(Node $node): void { + while ($node instanceof Node\Expr\ArrayDimFetch || $node instanceof Node\Expr\PropertyFetch) { + $node->setAttribute('typephp_write_context', true); + $node = $node->var; + } + $node->setAttribute('typephp_write_context', true); - if ($node instanceof Node\Expr\ArrayDimFetch || $node instanceof Node\Expr\PropertyFetch) { - $this->markWriteContext($node->var); - } elseif ($node instanceof Node\Expr\Array_ || $node instanceof Node\Expr\List_) { + + if ($node instanceof Node\Expr\Array_ || $node instanceof Node\Expr\List_) { foreach ($node->items as $item) { if ($item !== null) { $this->markWriteContext($item->value); diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 5c9b6ab..8d44185 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -557,6 +557,10 @@ private static function isIterableCandidate(Node\Param $param, string $docText): */ private static function typeMatchesName(Node\Identifier|Node\Name|Node\ComplexType|null $type, array $targetNames): bool { + while ($type instanceof Node\NullableType) { + $type = $type->type; + } + if ($type instanceof Node\Identifier) { return isset($targetNames[strtolower($type->name)]); } diff --git a/src/Internal/Checker/ConditionalChecker.php b/src/Internal/Checker/ConditionalChecker.php index 9c98d19..e09f5b0 100644 --- a/src/Internal/Checker/ConditionalChecker.php +++ b/src/Internal/Checker/ConditionalChecker.php @@ -38,12 +38,12 @@ final class ConditionalChecker */ public static function containsConditional(TypeNode $node): bool { - if ($node instanceof ConditionalTypeNode || $node instanceof ConditionalTypeForParameterNode) { - return true; + while ($node instanceof NullableTypeNode || $node instanceof ArrayTypeNode) { + $node = $node->type; } - if ($node instanceof NullableTypeNode || $node instanceof ArrayTypeNode) { - return self::containsConditional($node->type); + if ($node instanceof ConditionalTypeNode || $node instanceof ConditionalTypeForParameterNode) { + return true; } if ($node instanceof GenericTypeNode) { diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 7cccb1f..e89a7f4 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -369,12 +369,12 @@ private static function wrapCollectionCallables(TypeNode $typeNode, array $value */ private static function isArrayShapeType(TypeNode $node): bool { - if ($node instanceof ArrayShapeNode) { - return true; + while ($node instanceof NullableTypeNode) { + $node = $node->type; } - if ($node instanceof NullableTypeNode) { - return self::isArrayShapeType($node->type); + if ($node instanceof ArrayShapeNode) { + return true; } if ($node instanceof UnionTypeNode) { @@ -800,6 +800,10 @@ private static function substituteStaticPropertyGenerics(TypeNode $typeNode, str */ private static function needsContextResolution(TypeNode $node): bool { + while ($node instanceof NullableTypeNode || $node instanceof ArrayTypeNode) { + $node = $node->type; + } + if ($node instanceof ThisTypeNode || $node instanceof OffsetAccessTypeNode || $node instanceof ConditionalTypeNode || $node instanceof ConditionalTypeForParameterNode) { return true; } @@ -826,10 +830,6 @@ private static function needsContextResolution(TypeNode $node): bool return false; } - if ($node instanceof NullableTypeNode || $node instanceof ArrayTypeNode) { - return self::needsContextResolution($node->type); - } - if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { foreach ($node->types as $subType) { if (self::needsContextResolution($subType)) { @@ -922,6 +922,10 @@ private static function getTypeParserComponents(): array private static function shouldValidateType(TypeNode $node): bool { + while ($node instanceof NullableTypeNode) { + $node = $node->type; + } + $checkArrays = Config::isInlineArraysEnabled(); if ($node instanceof CallableTypeNode) { @@ -967,10 +971,6 @@ private static function shouldValidateType(TypeNode $node): bool return Config::isInlineObjectsEnabled(); } - if ($node instanceof NullableTypeNode) { - return self::shouldValidateType($node->type); - } - if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { foreach ($node->types as $t) { if (self::shouldValidateType($t)) { diff --git a/src/Internal/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index 0bf87e0..55e1d9a 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -566,20 +566,28 @@ public static function extractAliases( } /** - * Resolves an imported type alias (@phpstan-import-type / @psalm-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 iteratively. */ public static function resolveImportedTypeAlias(string $fqcn, string $importedAlias): ?TypeNode { - if (! ClassNameValidator::isValid($fqcn) || (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn) && ! enum_exists($fqcn))) { - return null; - } + $visited = []; - try { - $stubDoc = StubManager::getClassDoc($fqcn); - $ref = new \ReflectionClass($fqcn); - $doc = $stubDoc ?? $ref->getDocComment(); + while (ClassNameValidator::isValid($fqcn) && (class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn))) { + $chainKey = $fqcn . '::' . $importedAlias; + if (isset($visited[$chainKey])) { + return null; + } + $visited[$chainKey] = true; + + try { + $stubDoc = StubManager::getClassDoc($fqcn); + $ref = new \ReflectionClass($fqcn); + $doc = $stubDoc ?? $ref->getDocComment(); + + if ($doc === false || $doc === null) { + return null; + } - if ($doc !== false && $doc !== null) { $phpDocNode = self::parseDocString($doc); $targetAliases = []; @@ -594,20 +602,27 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl ...$phpDocNode->getTagsByName('@phpstan-import-type'), ]; + $nextFound = false; 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); + $fqcn = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + $importedAlias = $importTag->importedAlias; + $nextFound = true; - return self::resolveImportedTypeAlias($nextFqcn, $importTag->importedAlias); + break; } } } + + if (! $nextFound) { + return null; + } + } catch (\Throwable $e) { + return null; } - } catch (\Throwable $e) { - // Silently ignore unresolvable types } return null; diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 30423d5..19bca34 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -190,6 +190,10 @@ public static function typeReferencesTemplate(?TypeNode $node, array $templateNa return false; } + while ($node instanceof ArrayTypeNode || $node instanceof NullableTypeNode) { + $node = $node->type; + } + if ($node instanceof IdentifierTypeNode) { return isset($templateNames[$node->name]); } @@ -225,10 +229,6 @@ public static function typeReferencesTemplate(?TypeNode $node, array $templateNa || self::typeReferencesTemplate($node->offset, $templateNames); } - if ($node instanceof ArrayTypeNode || $node instanceof NullableTypeNode) { - return self::typeReferencesTemplate($node->type, $templateNames); - } - if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { foreach ($node->types as $t) { if (self::typeReferencesTemplate($t, $templateNames)) { @@ -1709,8 +1709,19 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo } if ($node instanceof IdentifierTypeNode) { - if (isset($aliases[$node->name])) { - return self::substituteAliases($aliases[$node->name], $aliases); + $visited = []; + while (isset($aliases[$node->name])) { + if (isset($visited[$node->name])) { + break; + } + $visited[$node->name] = true; + $target = $aliases[$node->name]; + + if ($target instanceof IdentifierTypeNode) { + $node = $target; + } else { + return self::substituteAliases($target, $aliases); + } } return $node; diff --git a/src/Internal/Generics/TemplateSubstitutor.php b/src/Internal/Generics/TemplateSubstitutor.php index a7f646f..1c91c81 100644 --- a/src/Internal/Generics/TemplateSubstitutor.php +++ b/src/Internal/Generics/TemplateSubstitutor.php @@ -103,7 +103,7 @@ public static function normalizeIntersection(array $types): TypeNode } /** - * Recursively inlines nested nodes of the given container class. + * Inlines nested nodes of the given container class iteratively while preserving in-order traversal. * * @param array $types * @param class-string|class-string $containerClass @@ -116,8 +116,16 @@ private static function flatten(array $types, string $containerClass): array foreach ($types as $t) { if ($t instanceof $containerClass) { - foreach (self::flatten($t->types, $containerClass) as $inner) { - $result[] = $inner; + $stack = array_reverse($t->types); + while ($stack !== []) { + $item = array_pop($stack); + if ($item instanceof $containerClass) { + for ($k = \count($item->types) - 1; $k >= 0; $k--) { + $stack[] = $item->types[$k]; + } + } else { + $result[] = $item; + } } } else { $result[] = $t; diff --git a/src/Internal/Resolver/HierarchyResolver.php b/src/Internal/Resolver/HierarchyResolver.php index a2e6ee5..8f16766 100644 --- a/src/Internal/Resolver/HierarchyResolver.php +++ b/src/Internal/Resolver/HierarchyResolver.php @@ -177,7 +177,7 @@ public static function getMethodHierarchy(ReflectionMethod $ref): array /** * Builds an array of ReflectionClasses representing the complete inheritance hierarchy from child to root. - * Recursively traverses parent classes, implemented interfaces, and used traits across all levels. + * Traverses parent classes, implemented interfaces, and used traits across all levels iteratively. * * @param ReflectionClass $ref * @@ -190,32 +190,40 @@ public static function getClassHierarchy(ReflectionClass $ref): array return self::$classHierarchyCache[$cacheKey]; } - $hierarchy = []; - $visited = []; + /** @var list> $hierarchy */ + $hierarchy = [$ref]; + $visited = [$ref->getName() => true]; + $i = 0; - $collect = function (ReflectionClass $class) use (&$collect, &$hierarchy, &$visited): void { - $name = $class->getName(); - if (isset($visited[$name])) { - return; - } - $visited[$name] = true; - $hierarchy[] = $class; + while (isset($hierarchy[$i])) { + $current = $hierarchy[$i]; + $i++; - $parent = $class->getParentClass(); + $parent = $current->getParentClass(); if ($parent !== false) { - $collect($parent); + $pName = $parent->getName(); + if (! isset($visited[$pName])) { + $visited[$pName] = true; + $hierarchy[] = $parent; + } } - foreach ($class->getInterfaces() as $interface) { - $collect($interface); + foreach ($current->getInterfaces() as $interface) { + $ifName = $interface->getName(); + if (! isset($visited[$ifName])) { + $visited[$ifName] = true; + $hierarchy[] = $interface; + } } - foreach ($class->getTraits() as $trait) { - $collect($trait); + foreach ($current->getTraits() as $trait) { + $trName = $trait->getName(); + if (! isset($visited[$trName])) { + $visited[$trName] = true; + $hierarchy[] = $trait; + } } - }; - - $collect($ref); + } return self::$classHierarchyCache[$cacheKey] = $hierarchy; } diff --git a/src/Internal/Validator/NullableValidator.php b/src/Internal/Validator/NullableValidator.php index f654ee1..6c1f02a 100644 --- a/src/Internal/Validator/NullableValidator.php +++ b/src/Internal/Validator/NullableValidator.php @@ -19,7 +19,10 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - /** @var NullableTypeNode $node */ - return $registry->validate($value, $node->type, $context, $isSensitive); + while ($node instanceof NullableTypeNode) { + $node = $node->type; + } + + return $registry->validate($value, $node, $context, $isSensitive); } } From 86b2ada1a4781cf546908c7f8133a3ac170aa7c7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 17:51:19 +0800 Subject: [PATCH 05/23] Impove performance on identified crititical hotpaths --- src/Internal/Generics/TemplateManager.php | 53 ++++++++++++++++++- src/Internal/Resolver/SpecialTypeResolver.php | 2 +- .../Validator/IdentifierValidator.php | 20 +++++++ .../Validator/TypeValidatorRegistry.php | 4 ++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index dae0c6c..077bc39 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1187,10 +1187,52 @@ private static function collectDocsForClassHierarchyMember(\ReflectionClass $hie */ public static function checkVariance(TypeNode $existing, TypeNode $expected, string $variance): bool { + if ($existing === $expected || $variance === GenericTypeNode::VARIANCE_BIVARIANT) { + return true; + } + + if ($existing instanceof IdentifierTypeNode && $expected instanceof IdentifierTypeNode) { + $existingName = $existing->name; + $expectedName = $expected->name; + + if ($existingName === $expectedName || $expectedName === 'mixed' || $expectedName === '*') { + return true; + } + + $lowerExpected = strtolower($expectedName); + $lowerExisting = strtolower($existingName); + + if ($lowerExpected === 'mixed' || $lowerExpected === '*') { + return true; + } + + if ($lowerExpected === 'object' && ClassNameValidator::isValid($existingName) && self::isRealTypeSymbol($existingName)) { + return true; + } + + if (self::isScalarSubtype($lowerExisting, $lowerExpected)) { + return true; + } + + if ($variance === GenericTypeNode::VARIANCE_COVARIANT && isset(self::COLLECTION_SUBTYPES[$lowerExpected])) { + return isset(self::COLLECTION_SUBTYPES[$lowerExpected][$lowerExisting]); + } + + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + return self::isSubclass($existingName, $expectedName); + } + + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + return self::isSubclass($expectedName, $existingName); + } + + return false; + } + $existingStr = (string) $existing; $expectedStr = (string) $expected; - if ($existingStr === $expectedStr || $variance === GenericTypeNode::VARIANCE_BIVARIANT || $expectedStr === 'mixed' || $expectedStr === '*') { + if ($existingStr === $expectedStr || $expectedStr === 'mixed' || $expectedStr === '*') { return true; } @@ -1379,7 +1421,14 @@ private static function checkExistingIntersectionVariance(IntersectionTypeNode $ private static function checkNestedGenericVariance(GenericTypeNode $existing, GenericTypeNode $expected): bool { $expectedClassName = $expected->type->name; - if (! is_a($existing->type->name, $expectedClassName, true)) { + $lowerExpected = strtolower($expectedClassName); + + if (isset(self::COLLECTION_SUBTYPES[$lowerExpected])) { + $lowerExisting = strtolower($existing->type->name); + if (! isset(self::COLLECTION_SUBTYPES[$lowerExpected][$lowerExisting])) { + return false; + } + } elseif (! is_a($existing->type->name, $expectedClassName, true)) { return false; } diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index ba557d9..3a4e3b3 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -1249,7 +1249,7 @@ private static function symbolExists(string $symbol): bool */ public static function isBuiltInTypeKeyword(string $name): bool { - return isset(self::BUILTIN_TYPE_KEYWORDS[strtolower($name)]); + return isset(self::BUILTIN_TYPE_KEYWORDS[$name]) || isset(self::BUILTIN_TYPE_KEYWORDS[strtolower($name)]); } /** diff --git a/src/Internal/Validator/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index 80b8a1d..b0b32de 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -22,6 +22,26 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var IdentifierTypeNode $identifierNode */ $identifierNode = $node; $name = $identifierNode->name; + + if ($name === 'int') { + return \is_int($value) ? null : ErrorFactory::createError($context . ' must be of type int, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'string') { + return \is_string($value) ? null : ErrorFactory::createError($context . ' must be of type string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'bool') { + return \is_bool($value) ? null : ErrorFactory::createError($context . ' must be of type bool, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'array') { + return \is_array($value) ? null : ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'mixed') { + return null; + } + if ($name === 'float') { + return (\is_float($value) || \is_int($value)) ? null : ErrorFactory::createError($context . ' must be of type float, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + $lower = strtolower($name); $ok = match ($lower) { diff --git a/src/Internal/Validator/TypeValidatorRegistry.php b/src/Internal/Validator/TypeValidatorRegistry.php index 46662a4..f3abfc2 100644 --- a/src/Internal/Validator/TypeValidatorRegistry.php +++ b/src/Internal/Validator/TypeValidatorRegistry.php @@ -85,6 +85,10 @@ public function __construct() */ public function validate(mixed $value, TypeNode $node, string $context = '', bool $isSensitive = false): ?ErrorMessage { + if ($node instanceof IdentifierTypeNode) { + return $this->identifierValidator->validate($value, $node, $context, $this, $isSensitive); + } + $validator = $this->validatorMap[$node::class] ?? null; if ($validator === null) { return null; From 3deea668406a983a62a782a9c9cbe31146d4f366 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 18:13:20 +0800 Subject: [PATCH 06/23] Enhance type validation by adding checks for IdentifierTypeNode in ArrayValidator, GenericValidator, and UnionValidator for improve performance --- src/Internal/Checker/ParamChecker.php | 10 +-- src/Internal/Validator/ArrayValidator.php | 53 ++++++++++++ src/Internal/Validator/GenericValidator.php | 96 +++++++++++++++++++++ src/Internal/Validator/UnionValidator.php | 23 ++++- 4 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index eb965f1..7622615 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -265,7 +265,7 @@ private static function validateSimpleParams( ): ?ErrorMessage { foreach ($types as $paramName => $typeNode) { if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { - if (ConditionalChecker::containsConditional($typeNode)) { + if (! ($typeNode instanceof IdentifierTypeNode) && ConditionalChecker::containsConditional($typeNode)) { $typeNode = ConditionalChecker::resolve($typeNode, $vars, [], $registry, $effectiveFunction); } @@ -422,14 +422,14 @@ public static function resolveEffectiveFunction(string $function, object|string| return $function; } - if (str_starts_with($function, $actualClassName . '::') && HierarchyResolver::getTraitAliases($actualClassName) === []) { - return $function; - } - if (isset(self::$effectiveFunctionCache[$function][$actualClassName])) { return self::$effectiveFunctionCache[$function][$actualClassName]; } + if (str_starts_with($function, $actualClassName . '::') && HierarchyResolver::getTraitAliases($actualClassName) === []) { + return self::$effectiveFunctionCache[$function][$actualClassName] = $function; + } + [$classOrTrait, $methodName] = explode('::', $function, 2); $effectiveFunction = ($actualClassName !== $classOrTrait) diff --git a/src/Internal/Validator/ArrayValidator.php b/src/Internal/Validator/ArrayValidator.php index 59d869e..3352a41 100644 --- a/src/Internal/Validator/ArrayValidator.php +++ b/src/Internal/Validator/ArrayValidator.php @@ -6,6 +6,7 @@ use Generator; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use Traversable; use TypePHP\Internal\Diagnostic\ErrorFactory; @@ -44,6 +45,58 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count); } + if ($arrayNode->type instanceof IdentifierTypeNode) { + $elemName = $arrayNode->type->name; + + if ($elemName === 'int') { + foreach ($value as $k => $v) { + if (! \is_int($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'string') { + foreach ($value as $k => $v) { + if (! \is_string($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'bool') { + foreach ($value as $k => $v) { + if (! \is_bool($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'float') { + foreach ($value as $k => $v) { + if (! \is_float($v) && ! \is_int($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + } + foreach ($value as $k => $v) { $err = $registry->validate($v, $arrayNode->type, '', $isSensitive); if ($err !== null) { diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 32ccd7b..7810e1f 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -501,6 +501,50 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return null; } + if ($valueTypeNode instanceof IdentifierTypeNode) { + $elemName = $valueTypeNode->name; + + if ($elemName === 'int') { + foreach ($value as $k => $v) { + if (! \is_int($v)) { + return ErrorFactory::createError($context . '[' . $k . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'string') { + foreach ($value as $k => $v) { + if (! \is_string($v)) { + return ErrorFactory::createError($context . '[' . $k . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'bool') { + foreach ($value as $k => $v) { + if (! \is_bool($v)) { + return ErrorFactory::createError($context . '[' . $k . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'float') { + foreach ($value as $k => $v) { + if (! \is_float($v) && ! \is_int($v)) { + return ErrorFactory::createError($context . '[' . $k . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + } + foreach ($value as $k => $v) { $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valueTypeNode, '', $isSensitive) @@ -588,6 +632,58 @@ private function validateSingleTypeArray( return null; } + if ($valTypeNode instanceof IdentifierTypeNode) { + $elemName = $valTypeNode->name; + + if ($elemName === 'int') { + foreach ($value as $k => $v) { + if (! \is_int($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'string') { + foreach ($value as $k => $v) { + if (! \is_string($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'bool') { + foreach ($value as $k => $v) { + if (! \is_bool($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'float') { + foreach ($value as $k => $v) { + if (! \is_float($v) && ! \is_int($v)) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + } + foreach ($value as $k => $v) { $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) diff --git a/src/Internal/Validator/UnionValidator.php b/src/Internal/Validator/UnionValidator.php index f7c2f15..24ad17f 100644 --- a/src/Internal/Validator/UnionValidator.php +++ b/src/Internal/Validator/UnionValidator.php @@ -30,7 +30,8 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali foreach ($unionNode->types as $type) { if ($type instanceof IdentifierTypeNode) { - $name = strtolower($type->name); + $name = $type->name; + if ($name === 'object' && \is_object($value)) { return null; } @@ -49,6 +50,26 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali if ($name === 'class-string' && \is_string($value) && ClassNameValidator::isValidClassString($value)) { return null; } + + $lower = strtolower($name); + if ($lower === 'object' && \is_object($value)) { + return null; + } + if (($lower === 'int' || $lower === 'integer') && \is_int($value)) { + return null; + } + if ($lower === 'string' && \is_string($value)) { + return null; + } + if (($lower === 'bool' || $lower === 'boolean') && \is_bool($value)) { + return null; + } + if ($lower === 'null' && $value === null) { + return null; + } + if ($lower === 'class-string' && \is_string($value) && ClassNameValidator::isValidClassString($value)) { + return null; + } } } From 98cf42cee88aa75ec4fdc4740f1b5b5166bad41e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 8 Oct 2026 18:58:06 +0800 Subject: [PATCH 07/23] Refactor ReturnChecker and RuntimeTypeChecker to improve conditional checks for better clarity and performance --- src/Internal/Checker/ReturnChecker.php | 6 +++--- src/Internal/RuntimeTypeChecker.php | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 285a062..e8939e6 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -342,7 +342,7 @@ private static function evaluateReturn( $hasGenerics = (\count($templates) > 0); $hasAliases = (\count($aliases) > 0); - $hasConditionals = ConditionalChecker::containsConditional($returnTypeNode); + $hasConditionals = ! ($returnTypeNode instanceof IdentifierTypeNode) && ConditionalChecker::containsConditional($returnTypeNode); if (! $hasGenerics && ! $hasAliases && ! $hasConditionals && ! ($returnTypeNode instanceof CallableTypeNode)) { $isDynamic = $contract['returnIsDynamic'] ?? (str_contains((string) $returnTypeNode, 'static') || str_contains((string) $returnTypeNode, '$this')); @@ -354,8 +354,8 @@ private static function evaluateReturn( } if ($resolvedType instanceof IdentifierTypeNode) { - $lower = strtolower($resolvedType->name); - if ($lower === 'mixed' || $lower === 'array') { + $name = $resolvedType->name; + if ($name === 'mixed' || $name === 'array' || strtolower($name) === 'mixed' || strtolower($name) === 'array') { return $value; } } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index ddafe3e..5834135 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -452,17 +452,19 @@ public static function checkReturn(string $function, mixed $value, object|string return $value; } - $isMagicCall = str_contains($effectiveFunction, '__call'); - $isMagicGet = str_ends_with($effectiveFunction, '::__get'); + $isDynamicDispatch = str_contains($effectiveFunction, '::__') + && (str_contains($effectiveFunction, '__call') || str_ends_with($effectiveFunction, '::__get')); - if (! $isMagicCall && ! $isMagicGet && (isset(ReturnChecker::$noReturnContractCache[$function]) || isset(ReturnChecker::$noReturnContractCache[$effectiveFunction]))) { - ReturnChecker::$noReturnContractCache[$function] = true; + if (! $isDynamicDispatch) { + if (isset(ReturnChecker::$noReturnContractCache[$function]) || isset(ReturnChecker::$noReturnContractCache[$effectiveFunction])) { + ReturnChecker::$noReturnContractCache[$function] = true; - return $value; + return $value; + } } $contract = DocblockParser::parse($effectiveFunction); - if (! $isMagicCall && ! $isMagicGet && ($contract['returnUnconstrained'] ?? false)) { + if (! $isDynamicDispatch && ($contract['returnUnconstrained'] ?? false)) { return $value; } From 34dfb0884a27d8bb365062a13f081ba4dccbb506 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 12:12:56 +0800 Subject: [PATCH 08/23] Refactor TemplateManager to enhance type resolution and add Multi-Level Generic Template Forwarding tests for improved type safety --- src/Internal/Generics/TemplateManager.php | 6 +- src/TypePHP.php | 2 +- .../MultiLevelGenericForwardingTest.php | 136 ++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 077bc39..aa907e8 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1137,7 +1137,11 @@ private static function collectInheritedGenericTagBindings( foreach ($parentTemplateNames as $idx => $templateName) { if (isset($normalizedGenericTypes[$idx])) { - $resolved = self::resolveTypeNodeAst($normalizedGenericTypes[$idx], $hierClass); + $typeArg = $normalizedGenericTypes[$idx]; + if ($bindings !== []) { + $typeArg = TemplateSubstitutor::substitute($typeArg, $bindings); + } + $resolved = self::resolveTypeNodeAst($typeArg, $hierClass); if ($resolved instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); diff --git a/src/TypePHP.php b/src/TypePHP.php index d7919d6..3a885d3 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -61,7 +61,7 @@ public static function getGenericType(object $instance, ?string $templateName = return $types[$templateName]; } - if (\count($types) === 1) { + if ($templateName === null && \count($types) === 1) { return reset($types); } diff --git a/tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php b/tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php new file mode 100644 index 0000000..5bd8c58 --- /dev/null +++ b/tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php @@ -0,0 +1,136 @@ + + */ +class DirectDogRepositoryFixture extends BaseAnimalRepositoryFixture +{ +} + +/** + * @template U of ForwardingAnimal + * + * @extends BaseAnimalRepositoryFixture + */ +class MiddleAnimalRepositoryFixture extends BaseAnimalRepositoryFixture +{ +} + +/** + * @extends MiddleAnimalRepositoryFixture + */ +class DogRepositoryFixture extends MiddleAnimalRepositoryFixture +{ +} + +/** + * 3-Level hierarchy: Root -> Level1 -> Level2 -> Concrete + * + * @template V of ForwardingAnimal + * + * @extends MiddleAnimalRepositoryFixture + */ +class Level2AnimalRepositoryFixture extends MiddleAnimalRepositoryFixture +{ +} + +/** + * @extends Level2AnimalRepositoryFixture + */ +class DeepDogRepositoryFixture extends Level2AnimalRepositoryFixture +{ +} + +describe('Multi-Level Generic Template Forwarding Inheritance', function () { + test('1. Direct specialization binds T to Dog and rejects Cat', function () { + $direct = new DirectDogRepositoryFixture(); + + expect(TypePHP::getGenericType($direct, 'T'))->toBe(ForwardingDog::class); + + $direct->save(new ForwardingDog()); + + expect(fn () => $direct->save(new ForwardingCat())) + ->toThrow(TypeError::class, 'must be of type ' . ForwardingDog::class) + ; + }); + + test('2. Two-level specialization binds both U and T to Dog upon instantiation', function () { + $repo = new DogRepositoryFixture(); + + $bindings = TypePHP::getGenericTypes($repo); + + expect($bindings)->toHaveKey('U') + ->and($bindings)->toHaveKey('T') + ->and($bindings['U'])->toBe(ForwardingDog::class) + ->and($bindings['T'])->toBe(ForwardingDog::class) + ; + }); + + test('3. Fresh DogRepository rejects Cat on the very first call to save()', function () { + $fresh = new DogRepositoryFixture(); + + expect(fn () => $fresh->save(new ForwardingCat())) + ->toThrow(TypeError::class, 'must be of type ' . ForwardingDog::class) + ; + }); + + test('4. Fresh DogRepository rejects unrelated User mentioning Dog, not generic Animal', function () { + $freshUser = new DogRepositoryFixture(); + + expect(fn () => $freshUser->save(new ForwardingUser())) + ->toThrow(TypeError::class, 'must be of type ' . ForwardingDog::class) + ; + }); + + test('5. Three-level specialization (Root -> Mid -> Sub -> Leaf) forwards T to Dog', function () { + $deep = new DeepDogRepositoryFixture(); + + $bindings = TypePHP::getGenericTypes($deep); + + expect($bindings)->toHaveKey('V') + ->and($bindings)->toHaveKey('U') + ->and($bindings)->toHaveKey('T') + ->and($bindings['T'])->toBe(ForwardingDog::class) + ; + + expect(fn () => $deep->save(new ForwardingCat())) + ->toThrow(TypeError::class, 'must be of type ' . ForwardingDog::class) + ; + }); +}); From 1e94af91c11e0583e4a0c1cc3f53e42ca7eac723 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 14:11:42 +0800 Subject: [PATCH 09/23] Enhance callable type handling by refining template resolution and adding tests for callable-local generics --- src/Internal/Docblock/DocblockNormalizer.php | 4 +- src/Internal/Generics/TemplateSubstitutor.php | 22 +- src/Internal/Resolver/SpecialTypeResolver.php | 60 +++- src/Internal/Wrapper/CallableWrapper.php | 327 ++++++++++++++++-- .../CallableLocalGenericsTest.php | 134 +++++++ 5 files changed, 512 insertions(+), 35 deletions(-) create mode 100644 tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php diff --git a/src/Internal/Docblock/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index 5f29646..0ce089d 100644 --- a/src/Internal/Docblock/DocblockNormalizer.php +++ b/src/Internal/Docblock/DocblockNormalizer.php @@ -53,10 +53,8 @@ public static function normalize(string $doc): string } if (stripos($doc, 'callable') !== false || stripos($doc, 'closure') !== false) { - // 1. Auto-complete omitted return types to ': mixed' - $doc = preg_replace('/(callable|Closure|static-closure)\s*(\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))(?!\s*:)/i', '$1$2: mixed', $doc) ?? $doc; + $doc = preg_replace('/(callable|Closure|static-closure)(?:<[^>]+>)?\s*(\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))(?!\s*:)/i', '$1$2: mixed', $doc) ?? $doc; - // 2. Auto-parenthesize unparenthesized nested return callables (Mago / Right-associative functional currying) if (str_contains($doc, ':')) { $doc = self::normalizeNestedCallables($doc); } diff --git a/src/Internal/Generics/TemplateSubstitutor.php b/src/Internal/Generics/TemplateSubstitutor.php index 1c91c81..b4ebe7a 100644 --- a/src/Internal/Generics/TemplateSubstitutor.php +++ b/src/Internal/Generics/TemplateSubstitutor.php @@ -234,20 +234,30 @@ private static function substituteIdentifier( } /** - * @param array $boundTemplates - * @param array $declaredTemplates - * @param array $visited - */ + * @param array $boundTemplates + * @param array $declaredTemplates + * @param array $visited + */ private static function substituteCallable( CallableTypeNode $node, array $boundTemplates, array $declaredTemplates, array $visited ): CallableTypeNode { + $localTemplateNames = []; + foreach ($node->templateTypes as $tTag) { + $localTemplateNames[$tTag->name] = true; + } + + $unboundLocalNames = array_diff_key($localTemplateNames, $boundTemplates); + $effectiveDeclared = $unboundLocalNames !== [] + ? array_diff_key($declaredTemplates, $unboundLocalNames) + : $declaredTemplates; + $parameters = []; foreach ($node->parameters as $param) { $parameters[] = new CallableTypeParameterNode( - self::substituteNode($param->type, $boundTemplates, $declaredTemplates, $visited), + self::substituteNode($param->type, $boundTemplates, $effectiveDeclared, $visited), $param->isReference, $param->isVariadic, $param->parameterName, @@ -255,7 +265,7 @@ private static function substituteCallable( ); } - $returnType = self::substituteNode($node->returnType, $boundTemplates, $declaredTemplates, $visited); + $returnType = self::substituteNode($node->returnType, $boundTemplates, $effectiveDeclared, $visited); return new CallableTypeNode( $node->identifier, diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 3a4e3b3..c151eac 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -677,10 +677,32 @@ private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionCla */ private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): CallableTypeNode { + $localTemplates = []; + $resolvedTemplateTypes = []; + foreach ($node->templateTypes as $tTag) { + $localTemplates[$tTag->name] = true; + $resolvedBound = $tTag->bound !== null + ? self::resolve($tTag->bound, $context, $thisObj) + : null; + $resolvedDefault = $tTag->default !== null + ? self::resolve($tTag->default, $context, $thisObj) + : null; + $resolvedTemplateTypes[] = new \PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode( + $tTag->name, + $resolvedBound, + $tTag->description, + $resolvedDefault + ); + } + $resolvedParameters = []; foreach ($node->parameters as $param) { + $paramType = ($param->type instanceof IdentifierTypeNode && isset($localTemplates[$param->type->name])) + ? $param->type + : self::resolve($param->type, $context, $thisObj); + $resolvedParameters[] = new CallableTypeParameterNode( - self::resolve($param->type, $context, $thisObj), + $paramType, $param->isReference, $param->isVariadic, $param->parameterName, @@ -688,9 +710,11 @@ private static function resolveCallable(CallableTypeNode $node, \ReflectionClass ); } - $resolvedReturnType = self::resolve($node->returnType, $context, $thisObj); + $resolvedReturnType = ($node->returnType instanceof IdentifierTypeNode && isset($localTemplates[$node->returnType->name])) + ? $node->returnType + : self::resolve($node->returnType, $context, $thisObj); - return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $resolvedTemplateTypes); } private static function resolveConstTypeForFile(ConstTypeNode $node, string $file): ConstTypeNode @@ -807,10 +831,32 @@ private static function resolveObjectShapeForFile(ObjectShapeNode $node, string private static function resolveCallableForFile(CallableTypeNode $node, string $file): CallableTypeNode { + $localTemplates = []; + $resolvedTemplateTypes = []; + foreach ($node->templateTypes as $tTag) { + $localTemplates[$tTag->name] = true; + $resolvedBound = $tTag->bound !== null + ? self::resolveForFile($tTag->bound, $file) + : null; + $resolvedDefault = $tTag->default !== null + ? self::resolveForFile($tTag->default, $file) + : null; + $resolvedTemplateTypes[] = new \PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode( + $tTag->name, + $resolvedBound, + $tTag->description, + $resolvedDefault + ); + } + $resolvedParameters = []; foreach ($node->parameters as $param) { + $paramType = ($param->type instanceof IdentifierTypeNode && isset($localTemplates[$param->type->name])) + ? clone $param->type + : self::resolveForFile($param->type, $file); + $resolvedParameters[] = new CallableTypeParameterNode( - self::resolveForFile($param->type, $file), + $paramType, $param->isReference, $param->isVariadic, $param->parameterName, @@ -818,9 +864,11 @@ private static function resolveCallableForFile(CallableTypeNode $node, string $f ); } - $resolvedReturnType = self::resolveForFile($node->returnType, $file); + $resolvedReturnType = ($node->returnType instanceof IdentifierTypeNode && isset($localTemplates[$node->returnType->name])) + ? clone $node->returnType + : self::resolveForFile($node->returnType, $file); - return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $resolvedTemplateTypes); } private static function extractOffsetKey(TypeNode $offsetType): string|int|null diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index dfef09b..9fa4865 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -5,11 +5,16 @@ namespace TypePHP\Internal\Wrapper; use Closure; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use ReflectionFunction; use TypeError; use TypePHP\Exception\TypeError as TypePHPTypeError; @@ -169,7 +174,11 @@ private static function createDispatcherClosure( if (! $hasAnyRef) { return function (...$args) use ($callable, $typeNode, $registry, $prefix) { - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -177,13 +186,17 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } if ($isVariadicRef) { return function (&...$args) use ($callable, $typeNode, $registry, $prefix) { - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -191,16 +204,20 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } if ($refPattern === [true]) { return function (mixed &$a = null) use ($callable, $typeNode, $registry, $prefix) { $args = [&$a]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a); @@ -208,16 +225,20 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } if ($refPattern === [true, false]) { return function (mixed &$a = null, mixed $b = null) use ($callable, $typeNode, $registry, $prefix) { $args = [&$a, $b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -225,16 +246,20 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } if ($refPattern === [false, true]) { return function (mixed $a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix) { $args = [$a, &$b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -242,16 +267,20 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } if ($refPattern === [true, true]) { return function (mixed &$a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix) { $args = [&$a, &$b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -259,14 +288,18 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } return function (&...$args) use ($callable, $typeNode, $registry, $prefix) { - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -274,12 +307,266 @@ private static function createDispatcherClosure( throw ErrorFactory::prepareException($e); } - self::validateCallbackByRefMutations($typeNode, $args, $prefix, $registry, $callable); + self::validateCallbackByRefMutations($effectiveTypeNode, $args, $prefix, $registry, $callable); - return self::validateCallbackReturn($typeNode, $result, $prefix, $registry, $callable); + return self::validateCallbackReturn($effectiveTypeNode, $result, $prefix, $registry, $callable); }; } + /** + * @param array $args + */ + private static function resolveEffectiveCallableTypeNode( + CallableTypeNode $typeNode, + array $args, + string $prefix, + TypeValidatorRegistry $registry, + mixed $callable = null + ): CallableTypeNode { + if ($typeNode->templateTypes === []) { + return $typeNode; + } + + /** @var array $localTemplates */ + $localTemplates = []; + foreach ($typeNode->templateTypes as $tTag) { + if (! SpecialTypeResolver::isBuiltInTypeKeyword($tTag->name)) { + $localTemplates[$tTag->name] = $tTag; + } + } + + if ($localTemplates === []) { + return $typeNode; + } + + $boundLocal = []; + $argValues = array_values($args); + + foreach ($typeNode->parameters as $index => $paramNode) { + $rawParamName = ltrim($paramNode->parameterName !== null ? $paramNode->parameterName : '', '$'); + + if ($paramNode->isVariadic) { + $variadicVals = \array_slice($argValues, $index); + foreach ($variadicVals as $vIdx => $vVal) { + self::inferLocalTemplateFromParam( + $paramNode->type, + $vVal, + $localTemplates, + $boundLocal, + $prefix . ' variadic argument #' . ($index + $vIdx + 1), + $registry, + $callable + ); + } + + break; + } + + $val = null; + $hasVal = false; + + if ($rawParamName !== '' && \array_key_exists($rawParamName, $args)) { + $val = $args[$rawParamName]; + $hasVal = true; + } elseif (\array_key_exists($index, $argValues)) { + $val = $argValues[$index]; + $hasVal = true; + } + + if ($hasVal) { + $argLabel = $rawParamName !== '' ? "\$$rawParamName" : ('argument #' . ($index + 1)); + self::inferLocalTemplateFromParam( + $paramNode->type, + $val, + $localTemplates, + $boundLocal, + $prefix . ' ' . $argLabel, + $registry, + $callable + ); + } + } + + if ($callable instanceof Closure) { + try { + $ref = new ReflectionFunction($callable); + if ($ref->hasReturnType()) { + $retType = $ref->getReturnType(); + if ($retType instanceof \ReflectionNamedType && $retType->getName() !== 'mixed') { + $retName = $retType->getName(); + if ($typeNode->returnType instanceof IdentifierTypeNode && isset($localTemplates[$typeNode->returnType->name])) { + $rName = $typeNode->returnType->name; + if (! isset($boundLocal[$rName])) { + $boundLocal[$rName] = new IdentifierTypeNode($retName); + } + } + } + } + } catch (\Throwable) { + } + } + + $resolvedParams = []; + foreach ($typeNode->parameters as $p) { + $resolvedParams[] = new CallableTypeParameterNode( + TemplateSubstitutor::substitute($p->type, $boundLocal, $localTemplates), + $p->isReference, + $p->isVariadic, + $p->parameterName, + $p->isOptional + ); + } + + $resolvedReturn = TemplateSubstitutor::substitute($typeNode->returnType, $boundLocal, $localTemplates); + + return new CallableTypeNode($typeNode->identifier, $resolvedParams, $resolvedReturn, $typeNode->templateTypes); + } + + /** + * @param array $localTemplates + * @param array $boundLocal + */ + private static function inferLocalTemplateFromParam( + TypeNode $typeNode, + mixed $value, + array $localTemplates, + array &$boundLocal, + string $context, + TypeValidatorRegistry $registry, + mixed $callable = null + ): void { + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\NullableTypeNode) { + if ($value !== null) { + self::inferLocalTemplateFromParam($typeNode->type, $value, $localTemplates, $boundLocal, $context, $registry, $callable); + } + + return; + } + + if ($typeNode instanceof IdentifierTypeNode && isset($localTemplates[$typeNode->name])) { + $tName = $typeNode->name; + $tTag = $localTemplates[$tName]; + + if ($tTag->bound !== null) { + $boundErr = $registry->validate($value, $tTag->bound, $context); + if ($boundErr !== null) { + if ($callable !== null && CallerBoundaryResolver::shouldBypassCallback($callable, $context)) { + return; + } + + $handled = ViolationCollector::handle($boundErr, 'callback', null); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($boundErr->getMessage())); + } + } + } + + $inferred = TemplateManager::inferTypeFromValue($value); + + if (! isset($boundLocal[$tName])) { + $boundLocal[$tName] = $inferred; + } else { + $existingType = $boundLocal[$tName]; + if ($registry->validate($value, $existingType, '') !== null) { + if ($tTag->bound !== null && $registry->validate($value, $tTag->bound, '') === null) { + $boundLocal[$tName] = self::unifyLocalTypes($existingType, $inferred); + } + } + } + + return; + } + + if ($typeNode instanceof ArrayTypeNode && \is_array($value)) { + foreach ($value as $item) { + self::inferLocalTemplateFromParam($typeNode->type, $item, $localTemplates, $boundLocal, $context, $registry, $callable); + } + + return; + } + + if ($typeNode instanceof GenericTypeNode) { + $baseName = strtolower($typeNode->type->name); + if (\in_array($baseName, ['list', 'array', 'iterable'], true) && \is_array($value)) { + $innerType = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; + if ($innerType !== null) { + foreach ($value as $item) { + self::inferLocalTemplateFromParam($innerType, $item, $localTemplates, $boundLocal, $context, $registry, $callable); + } + } + } + + return; + } + + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode && \is_array($value)) { + $nextIdx = 0; + foreach ($typeNode->items as $item) { + $key = match (true) { + $item->keyName instanceof ConstExprStringNode => $item->keyName->value, + $item->keyName instanceof ConstExprIntegerNode => (int) $item->keyName->value, + $item->keyName instanceof IdentifierTypeNode => $item->keyName->name, + $item->keyName !== null => (string) $item->keyName, + default => $nextIdx, + }; + if (\is_int($key)) { + $nextIdx = max($nextIdx, $key + 1); + } + if (\array_key_exists($key, $value)) { + self::inferLocalTemplateFromParam($item->valueType, $value[$key], $localTemplates, $boundLocal, $context, $registry, $callable); + } + } + + return; + } + + if ($typeNode instanceof ObjectShapeNode && \is_object($value)) { + foreach ($typeNode->items as $item) { + $prop = $item->keyName instanceof IdentifierTypeNode ? $item->keyName->name : (string) $item->keyName; + // @phpstan-ignore property.dynamicName + if (isset($value->$prop) || property_exists($value, $prop)) { + // @phpstan-ignore property.dynamicName + $propVal = $value->$prop; + self::inferLocalTemplateFromParam($item->valueType, $propVal, $localTemplates, $boundLocal, $context, $registry, $callable); + } + } + } + } + + private static function unifyLocalTypes(TypeNode $type1, TypeNode $type2): TypeNode + { + if ((string) $type1 === (string) $type2) { + return $type1; + } + + $types = []; + if ($type1 instanceof UnionTypeNode) { + $types = $type1->types; + } else { + $types[] = $type1; + } + + if ($type2 instanceof UnionTypeNode) { + foreach ($type2->types as $t) { + $types[] = $t; + } + } else { + $types[] = $type2; + } + + $unique = []; + $deduped = []; + foreach ($types as $t) { + $str = (string) $t; + if (! isset($unique[$str])) { + $unique[$str] = true; + $deduped[] = $t; + } + } + + return \count($deduped) === 1 ? $deduped[0] : new UnionTypeNode($deduped); + } + private static function validateCallbackReturn( CallableTypeNode $typeNode, mixed $result, diff --git a/tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php new file mode 100644 index 0000000..7b37618 --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php @@ -0,0 +1,134 @@ +(T): T $callback + */ +function testApplyCallableLocalGeneric(callable $callback, mixed $value): mixed +{ + return $callback($value); +} + +/** + * 2. Multiple callable-local templates + * + * @param callable(T): R $callback + */ +function testApplyCallableMultiLocalGeneric(callable $callback, mixed $value): mixed +{ + return $callback($value); +} + +/** + * 3. Bounded callable-local template + * + * @param callable(T): T $callback + */ +function testApplyCallableBoundedLocalGeneric(callable $callback, mixed $value): mixed +{ + return $callback($value); +} + +/** + * 4. Closure syntax with local template + * + * @param Closure(T): T $closure + */ +function testApplyClosureLocalGeneric(Closure $closure, mixed $value): mixed +{ + return $closure($value); +} + +describe('Callable-Local Generics (callable(T): T & Closure(T): T)', function () { + describe('1. Single Local Template (T): T', function () { + test('infers local T as int and returns int', function () { + $cb = fn (int $x): int => $x + 1; + + $result = testApplyCallableLocalGeneric($cb, 5); + expect($result)->toBe(6); + }); + + test('infers local T as string and returns string', function () { + $cb = fn (string $s): string => strtoupper($s); + + $result = testApplyCallableLocalGeneric($cb, 'hello'); + expect($result)->toBe('HELLO'); + }); + + test('throws TypeError when callback return type violates inferred local T', function () { + $badCb = fn (int $x): string => 'not_an_int'; + + expect(fn () => testApplyCallableLocalGeneric($badCb, 5)) + ->toThrow(TypeError::class, 'must be of type int') + ; + }); + + test('allows calling the same callback multiple times with different types without cross-call locking', function () { + /** @var callable(T): T $identity */ + $identity = fn ($x) => $x; + + expect(testApplyCallableLocalGeneric($identity, 42))->toBe(42) + ->and(testApplyCallableLocalGeneric($identity, 'world'))->toBe('world') + ; + }); + }); + + describe('2. Multiple Local Templates (T): R', function () { + test('infers T and R independently', function () { + $stringify = fn (int $x): string => "val_{$x}"; + + $result = testApplyCallableMultiLocalGeneric($stringify, 10); + expect($result)->toBe('val_10'); + }); + + test('throws TypeError when callback return violates inferred R', function () { + $badCb = fn (int $x): int => 999; + + /** @var callable(T): string $typedCb */ + $typedCb = $badCb; + + expect(fn () => testApplyCallableMultiLocalGeneric($typedCb, 10)) + ->toThrow(TypeError::class, 'must be of type string') + ; + }); + }); + + describe('3. Bounded Local Templates (T): T', function () { + test('accepts Dog satisfying bound Animal', function () { + $dog = new Dog(); + $echo = fn (Dog $d): Dog => $d; + + $result = testApplyCallableBoundedLocalGeneric($echo, $dog); + expect($result)->toBe($dog); + }); + + test('throws TypeError when argument violates bound Animal', function () { + $car = new Car(); + $echo = fn ($c) => $c; + + expect(fn () => testApplyCallableBoundedLocalGeneric($echo, $car)) + ->toThrow(TypeError::class, 'Animal') + ; + }); + }); + + describe('4. Closure Syntax Closure(T): T', function () { + test('works identically with Closure(T): T syntax', function () { + $closure = fn (int $x): int => $x * 2; + + $result = testApplyClosureLocalGeneric($closure, 21); + expect($result)->toBe(42); + }); + }); +}); From e9ae8e93d135e7f8aba870aae2831e0e2b5a1902 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 15:10:15 +0800 Subject: [PATCH 10/23] Enhance generator return type validation by adding checks for TReturn in GeneratorChecker and RuntimeTypeChecker, and implement corresponding tests for valid and invalid return values in GenericIteratorsAndGeneratorsTest. --- src/Internal/Ast/FunctionContractInjector.php | 41 ++++++++++ src/Internal/Checker/GeneratorChecker.php | 65 +++++++++++++--- src/Internal/RuntimeTypeChecker.php | 29 +++++++ .../GenericIteratorsAndGeneratorsTest.php | 76 +++++++++++++++++++ 4 files changed, 200 insertions(+), 11 deletions(-) diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 8d44185..4638953 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -761,6 +761,27 @@ public function enterNode(Node $n): int|Node|null ); } + if ($n instanceof Node\Stmt\Return_) { + if ($n->getAttribute('typephp_var_wrapped') === true || $n->getAttribute('typephp_generator_return_wrapped') === true) { + return null; + } + $n->setAttribute('typephp_generator_return_wrapped', true); + + $exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null')); + $checkCall = new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::checkGeneratorReturn'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg($exprToWrap), + new Node\Arg($this->thisArg), + ] + ); + + $n->expr = FunctionContractInjector::buildTernaryReturnExpr($checkCall, $n->getStartLine()); + + return $n; + } + return null; } }); @@ -768,6 +789,26 @@ public function enterNode(Node $n): int|Node|null /** @var array $newStmts */ $newStmts = $traverser->traverse($stmts); + $lastStmt = end($newStmts); + if (! $lastStmt instanceof Node\Stmt\Return_ && ! ($lastStmt instanceof Node\Stmt\Expression && $lastStmt->expr instanceof Node\Expr\Throw_)) { + $checkCall = new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::checkGeneratorReturn'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('null'))), + new Node\Arg($thisArg), + ] + ); + + $fallbackLine = $lastStmt instanceof Node\Stmt ? $lastStmt->getStartLine() : null; + $ternaryExpr = self::buildTernaryReturnExpr($checkCall, $fallbackLine); + + $retStmt = new Node\Stmt\Return_($ternaryExpr); + $retStmt->setAttribute('typephp_injected', true); + $retStmt->setAttribute('typephp_generator_return_wrapped', true); + $newStmts[] = $retStmt; + } + return $newStmts; } diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index 2519d77..33e1b6c 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -8,6 +8,8 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use TypePHP\Internal\Diagnostic\ErrorFactory; +use TypePHP\Internal\Diagnostic\TypeFormatter; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; @@ -15,7 +17,7 @@ use TypePHP\Internal\Validator\TypeValidatorRegistry; /** - * @internal Evaluates generator yield and send (TSend) type validations. + * @internal Evaluates generator yield, send (TSend), and return (TReturn) type validations. */ final class GeneratorChecker { @@ -28,17 +30,17 @@ final class GeneratorChecker /** * Cache for resolved yield & send types of static / non-generic generators: - * [$function] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}. + * [$function] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode, 3: ?TypeNode}. * - * @var array + * @var array */ private static array $staticYieldTypeCache = []; /** * 2D Cache for resolved yield & send types of generic generators: - * [$function][$templateSignature] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}. + * [$function][$templateSignature] => array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode, 3: ?TypeNode}. * - * @var array> + * @var array> */ private static array $genericYieldTypeCache = []; @@ -104,10 +106,42 @@ public static function checkYield( return $value; } + /** + * Validates a value returned from a generator via `return $value;` against TReturn. + */ + public static function checkReturn( + string $function, + mixed $value, + TypeValidatorRegistry $registry, + object|string|null $thisOrClass = null + ): mixed { + $types = self::resolveYieldAndSendTypes($function, $thisOrClass); + if ($types === null) { + return $value; + } + + $returnTypeNode = $types[3]; + if ($returnTypeNode === null) { + return $value; + } + + if ($returnTypeNode instanceof IdentifierTypeNode && strtolower($returnTypeNode->name) === 'void') { + if ($value !== null) { + return ErrorFactory::createError("$function(): Generator return value must be of type void, " . TypeFormatter::formatGivenValue($value) . ' returned'); + } + + return null; + } + + $err = $registry->validate($value, $returnTypeNode, "$function(): Generator return value (TReturn)"); + + return $err ?? $value; + } + /** * Resolves and caches the generator's yielded key, value, and sent types in memory. * - * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode}|null + * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode, 3: ?TypeNode}|null */ private static function resolveYieldAndSendTypes(string $function, object|string|null $thisOrClass): ?array { @@ -181,29 +215,38 @@ private static function resolveYieldAndSendTypes(string $function, object|string } /** - * Extracts yielded key, item, and sent (TSend) TypeNodes from a resolved generator/array AST node. + * Extracts yielded key, item, sent (TSend), and return (TReturn) TypeNodes from a resolved generator/array AST node. * - * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode} + * @return array{0: ?TypeNode, 1: ?TypeNode, 2: ?TypeNode, 3: ?TypeNode} */ private static function extractYieldTypes(TypeNode $returnTypeNode): array { $itemTypeNode = null; $keyTypeNode = null; $sendTypeNode = null; + $generatorReturnTypeNode = null; if ($returnTypeNode instanceof GenericTypeNode) { $typesCount = \count($returnTypeNode->genericTypes); if ($typesCount === 1) { $itemTypeNode = $returnTypeNode->genericTypes[0]; - } elseif ($typesCount >= 2) { + } elseif ($typesCount === 2) { + $keyTypeNode = $returnTypeNode->genericTypes[0]; + $itemTypeNode = $returnTypeNode->genericTypes[1]; + } elseif ($typesCount === 3) { + $keyTypeNode = $returnTypeNode->genericTypes[0]; + $itemTypeNode = $returnTypeNode->genericTypes[1]; + $sendTypeNode = $returnTypeNode->genericTypes[2]; + } elseif ($typesCount >= 4) { $keyTypeNode = $returnTypeNode->genericTypes[0]; $itemTypeNode = $returnTypeNode->genericTypes[1]; - $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; + $sendTypeNode = $returnTypeNode->genericTypes[2]; + $generatorReturnTypeNode = $returnTypeNode->genericTypes[3]; } } elseif ($returnTypeNode instanceof ArrayTypeNode) { $itemTypeNode = $returnTypeNode->type; } - return [$keyTypeNode, $itemTypeNode, $sendTypeNode]; + return [$keyTypeNode, $itemTypeNode, $sendTypeNode, $generatorReturnTypeNode]; } } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 5834135..7f7043b 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -521,6 +521,35 @@ public static function checkSend(string $function, mixed $sendValue, object|stri return $res; } + /** + * Validates a value returned from a generator against TReturn. + */ + public static function checkGeneratorReturn(string $function, mixed $value, object|string|null $thisOrClass = null): mixed + { + if (! Config::isEnabled() || ! Config::isReturnsEnabled()) { + return $value; + } + + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $value; + } + + $res = GeneratorChecker::checkReturn($function, $value, self::getRegistry(), $thisOrClass); + + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored()) { + return $value; + } + + return ViolationCollector::handle($res, 'return', $value, function: $effectiveFunction, target: 'return'); + } + + return $res; + } + /** * Validates yielded keys and values from a generator function against TKey and TValue. */ diff --git a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php index 3fad3ee..659b1f0 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php @@ -6,6 +6,50 @@ use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Iterators\GenericStreamService; +/** + * Generator with valid TReturn + * + * @param list $items + * + * @return Generator + */ +function testGeneratorWithValidReturn(array $items): Generator +{ + foreach ($items as $i => $item) { + yield $i => $item; + } + + return true; +} + +/** + * Generator with invalid TReturn + * + * @param list $items + * + * @return Generator + */ +function testGeneratorWithInvalidReturn(array $items): Generator +{ + foreach ($items as $i => $item) { + yield $i => $item; + } + + return 12345; // Violates bool! +} + +/** + * Generator declared to return void but returns a value + * + * @return Generator + */ +function testGeneratorWithInvalidVoidReturn(): Generator +{ + yield 0 => 'a'; + + return 'not_void'; +} + describe('Generic Iterables & Generators (@template T with iterable and Generator)', function () { describe('Generic Iterable Parameters (iterable)', function () { test('collects items from iterable matching inferred template T = int', function () { @@ -106,4 +150,36 @@ ; }); }); + + describe('Generator Return Value Validation (TReturn)', function () { + test('accepts valid TReturn value upon generator completion', function () { + $gen = testGeneratorWithValidReturn(['a', 'b']); + + foreach ($gen as $item) { + // consume yields + } + + expect($gen->getReturn())->toBeTrue(); + }); + + test('throws TypeError when generator return value violates TReturn contract', function () { + $gen = testGeneratorWithInvalidReturn(['a', 'b']); + + expect(function () use ($gen) { + foreach ($gen as $item) { + // consume yields + } + })->toThrow(TypeError::class, 'Generator return value (TReturn) must be of type bool, int (12345) given'); + }); + + test('throws TypeError when generator with TReturn = void returns a value', function () { + $gen = testGeneratorWithInvalidVoidReturn(); + + expect(function () use ($gen) { + foreach ($gen as $item) { + // consume yields + } + })->toThrow(TypeError::class, 'must be of type void'); + }); + }); }); From 7eb8eeb58536f344b9688a7479989047378d1612 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 15:37:40 +0800 Subject: [PATCH 11/23] Enhance CallableWrapper by adding support for specialized callable caching and refining type validation for callable parameters --- src/Internal/Wrapper/CallableWrapper.php | 130 ++++++++++++++++------- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index 9fa4865..7ccba22 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -7,11 +7,14 @@ use Closure; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -37,6 +40,14 @@ */ final class CallableWrapper { + /** + * Cache for monomorphized callable AST nodes by function, parameter name, and argument signature: + * [$function][$paramName][$argTypesSignature] => CallableTypeNode. + * + * @var array>> + */ + private static array $specializedCallableCache = []; + /** * Safely checks if a value is callable without triggering PHP 8.2+ deprecation warnings * on partially supported callables (e.g. 'static::method', ['static', 'method']). @@ -103,7 +114,7 @@ public static function wrap(string $function, string $paramName, mixed $callable $prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName"; if (self::isCallable($callable)) { - return self::wrapTypeNode($typeNode, $callable, $prefix, $registry); + return self::wrapTypeNode($typeNode, $callable, $prefix, $registry, $function, $paramName); } if (\is_array($callable) && $typeNode !== null) { @@ -120,7 +131,7 @@ public static function wrap(string $function, string $paramName, mixed $callable foreach ($callable as $k => $item) { if (self::isCallable($item)) { $itemPrefix = $prefix . (\is_int($k) ? "[$k]" : "['$k']"); - $wrappedArray[$k] = self::wrapTypeNode($innerCallableTypeNode, $item, $itemPrefix, $registry); + $wrappedArray[$k] = self::wrapTypeNode($innerCallableTypeNode, $item, $itemPrefix, $registry, $function, $paramName); } else { $wrappedArray[$k] = $item; } @@ -136,8 +147,14 @@ public static function wrap(string $function, string $paramName, mixed $callable /** * Wraps a callable with runtime argument and return value type validation based on a CallableTypeNode AST. */ - public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string $prefix, TypeValidatorRegistry $registry): mixed - { + public static function wrapTypeNode( + ?TypeNode $typeNode, + mixed $callable, + string $prefix, + TypeValidatorRegistry $registry, + string $function = '', + string $paramName = 'callback' + ): mixed { if (! ($typeNode instanceof CallableTypeNode) || ! self::isCallable($callable)) { return $callable; } @@ -146,7 +163,7 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string self::enforceClosureConstraints($identifierName, $callable, $prefix); /** @var callable $callable */ - return self::createDispatcherClosure($typeNode, $callable, $prefix, $registry); + return self::createDispatcherClosure($typeNode, $callable, $prefix, $registry, $function, $paramName); } /** @@ -156,7 +173,9 @@ private static function createDispatcherClosure( CallableTypeNode $typeNode, callable $callable, string $prefix, - TypeValidatorRegistry $registry + TypeValidatorRegistry $registry, + string $function = '', + string $paramName = 'callback' ): Closure { $hasAnyRef = false; $isVariadicRef = false; @@ -173,9 +192,9 @@ private static function createDispatcherClosure( } if (! $hasAnyRef) { - return function (...$args) use ($callable, $typeNode, $registry, $prefix) { + return function (...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -191,9 +210,9 @@ private static function createDispatcherClosure( } if ($isVariadicRef) { - return function (&...$args) use ($callable, $typeNode, $registry, $prefix) { + return function (&...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -211,10 +230,10 @@ private static function createDispatcherClosure( } if ($refPattern === [true]) { - return function (mixed &$a = null) use ($callable, $typeNode, $registry, $prefix) { + return function (mixed &$a = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a]; $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -232,10 +251,10 @@ private static function createDispatcherClosure( } if ($refPattern === [true, false]) { - return function (mixed &$a = null, mixed $b = null) use ($callable, $typeNode, $registry, $prefix) { + return function (mixed &$a = null, mixed $b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a, $b]; $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -253,10 +272,10 @@ private static function createDispatcherClosure( } if ($refPattern === [false, true]) { - return function (mixed $a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix) { + return function (mixed $a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [$a, &$b]; $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -274,10 +293,10 @@ private static function createDispatcherClosure( } if ($refPattern === [true, true]) { - return function (mixed &$a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix) { + return function (mixed &$a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a, &$b]; $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -294,9 +313,9 @@ private static function createDispatcherClosure( }; } - return function (&...$args) use ($callable, $typeNode, $registry, $prefix) { + return function (&...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $effectiveTypeNode = $typeNode->templateTypes !== [] - ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable) + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) : $typeNode; self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); @@ -321,13 +340,15 @@ private static function resolveEffectiveCallableTypeNode( array $args, string $prefix, TypeValidatorRegistry $registry, - mixed $callable = null + mixed $callable = null, + string $function = '', + string $paramName = 'callback' ): CallableTypeNode { if ($typeNode->templateTypes === []) { return $typeNode; } - /** @var array $localTemplates */ + /** @var array $localTemplates */ $localTemplates = []; foreach ($typeNode->templateTypes as $tTag) { if (! SpecialTypeResolver::isBuiltInTypeKeyword($tTag->name)) { @@ -339,6 +360,16 @@ private static function resolveEffectiveCallableTypeNode( return $typeNode; } + $sigParts = []; + foreach ($args as $arg) { + $sigParts[] = get_debug_type($arg); + } + $sig = implode('|', $sigParts); + + if ($function !== '' && isset(self::$specializedCallableCache[$function][$paramName][$sig])) { + return self::$specializedCallableCache[$function][$paramName][$sig]; + } + $boundLocal = []; $argValues = array_values($args); @@ -355,7 +386,8 @@ private static function resolveEffectiveCallableTypeNode( $boundLocal, $prefix . ' variadic argument #' . ($index + $vIdx + 1), $registry, - $callable + $callable, + $function ); } @@ -382,7 +414,8 @@ private static function resolveEffectiveCallableTypeNode( $boundLocal, $prefix . ' ' . $argLabel, $registry, - $callable + $callable, + $function ); } } @@ -418,12 +451,17 @@ private static function resolveEffectiveCallableTypeNode( } $resolvedReturn = TemplateSubstitutor::substitute($typeNode->returnType, $boundLocal, $localTemplates); + $monomorphizedNode = new CallableTypeNode($typeNode->identifier, $resolvedParams, $resolvedReturn, $typeNode->templateTypes); - return new CallableTypeNode($typeNode->identifier, $resolvedParams, $resolvedReturn, $typeNode->templateTypes); + if ($function !== '') { + self::$specializedCallableCache[$function][$paramName][$sig] = $monomorphizedNode; + } + + return $monomorphizedNode; } /** - * @param array $localTemplates + * @param array $localTemplates * @param array $boundLocal */ private static function inferLocalTemplateFromParam( @@ -433,11 +471,12 @@ private static function inferLocalTemplateFromParam( array &$boundLocal, string $context, TypeValidatorRegistry $registry, - mixed $callable = null + mixed $callable = null, + string $function = '' ): void { - if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\NullableTypeNode) { + if ($typeNode instanceof NullableTypeNode) { if ($value !== null) { - self::inferLocalTemplateFromParam($typeNode->type, $value, $localTemplates, $boundLocal, $context, $registry, $callable); + self::inferLocalTemplateFromParam($typeNode->type, $value, $localTemplates, $boundLocal, $context, $registry, $callable, $function); } return; @@ -448,7 +487,20 @@ private static function inferLocalTemplateFromParam( $tTag = $localTemplates[$tName]; if ($tTag->bound !== null) { - $boundErr = $registry->validate($value, $tTag->bound, $context); + $boundToValidate = $tTag->bound; + if ($function !== '') { + $boundToValidate = SpecialTypeResolver::resolve($tTag->bound, $function); + } elseif ($callable instanceof Closure) { + try { + $cFile = (new ReflectionFunction($callable))->getFileName(); + if ($cFile !== false && $cFile !== '') { + $boundToValidate = SpecialTypeResolver::resolveForFile($tTag->bound, $cFile); + } + } catch (\Throwable) { + } + } + + $boundErr = $registry->validate($value, $boundToValidate, $context); if ($boundErr !== null) { if ($callable !== null && CallerBoundaryResolver::shouldBypassCallback($callable, $context)) { return; @@ -467,8 +519,12 @@ private static function inferLocalTemplateFromParam( $boundLocal[$tName] = $inferred; } else { $existingType = $boundLocal[$tName]; - if ($registry->validate($value, $existingType, '') !== null) { - if ($tTag->bound !== null && $registry->validate($value, $tTag->bound, '') === null) { + if ($registry->validate($value, $existingType, '') !== null && $tTag->bound !== null) { + $boundToCheck = $function !== '' + ? SpecialTypeResolver::resolve($tTag->bound, $function) + : $tTag->bound; + + if ($registry->validate($value, $boundToCheck, '') === null) { $boundLocal[$tName] = self::unifyLocalTypes($existingType, $inferred); } } @@ -479,7 +535,7 @@ private static function inferLocalTemplateFromParam( if ($typeNode instanceof ArrayTypeNode && \is_array($value)) { foreach ($value as $item) { - self::inferLocalTemplateFromParam($typeNode->type, $item, $localTemplates, $boundLocal, $context, $registry, $callable); + self::inferLocalTemplateFromParam($typeNode->type, $item, $localTemplates, $boundLocal, $context, $registry, $callable, $function); } return; @@ -491,7 +547,7 @@ private static function inferLocalTemplateFromParam( $innerType = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; if ($innerType !== null) { foreach ($value as $item) { - self::inferLocalTemplateFromParam($innerType, $item, $localTemplates, $boundLocal, $context, $registry, $callable); + self::inferLocalTemplateFromParam($innerType, $item, $localTemplates, $boundLocal, $context, $registry, $callable, $function); } } } @@ -499,7 +555,7 @@ private static function inferLocalTemplateFromParam( return; } - if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode && \is_array($value)) { + if ($typeNode instanceof ArrayShapeNode && \is_array($value)) { $nextIdx = 0; foreach ($typeNode->items as $item) { $key = match (true) { @@ -513,7 +569,7 @@ private static function inferLocalTemplateFromParam( $nextIdx = max($nextIdx, $key + 1); } if (\array_key_exists($key, $value)) { - self::inferLocalTemplateFromParam($item->valueType, $value[$key], $localTemplates, $boundLocal, $context, $registry, $callable); + self::inferLocalTemplateFromParam($item->valueType, $value[$key], $localTemplates, $boundLocal, $context, $registry, $callable, $function); } } @@ -527,7 +583,7 @@ private static function inferLocalTemplateFromParam( if (isset($value->$prop) || property_exists($value, $prop)) { // @phpstan-ignore property.dynamicName $propVal = $value->$prop; - self::inferLocalTemplateFromParam($item->valueType, $propVal, $localTemplates, $boundLocal, $context, $registry, $callable); + self::inferLocalTemplateFromParam($item->valueType, $propVal, $localTemplates, $boundLocal, $context, $registry, $callable, $function); } } } From bc261ed9ebeb38033e56cb38baa94b8bf397e4dd Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 19:46:30 +0800 Subject: [PATCH 12/23] Enhance FileFilter and PathMatcher to exclude view templates and framework caches, and implement regression tests for constructor synthesis and expression semantics in ContractVisitor. --- src/Internal/Ast/ContractVisitor.php | 34 ++- src/Internal/Io/StreamWrapper.php | 34 ++- src/Internal/Util/FileFilter.php | 17 +- src/Internal/Util/PathMatcher.php | 22 +- ...iewTemplateAndConstructorExclusionTest.php | 280 ++++++++++++++++++ 5 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 tests/Contract/ViewTemplateAndConstructorExclusionTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index be28b84..d834687 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -140,9 +140,15 @@ public function enterNode(Node $node): ?array } if ($node instanceof Node\Stmt\Expression) { + $node->expr->setAttribute('typephp_is_standalone', true); + return $this->handleExpression($node); } + if ($node instanceof Node\Expr\ArrowFunction) { + $node->expr->setAttribute('typephp_is_standalone', true); + } + if ($node instanceof Node\Stmt\Foreach_) { $doc = $node->getDocComment(); if ($doc !== null && str_contains($doc->getText(), '@var')) { @@ -177,6 +183,17 @@ public function leaveNode(Node $node): ?Node } } + if ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) { + if (($node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PostDec) && $node->getAttribute('typephp_is_standalone') !== true) { + return null; + } + + $replacement = $this->handleIncDec($node); + if ($replacement !== null) { + return $replacement; + } + } + if ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) { $replacement = $this->handleIncDec($node); if ($replacement !== null) { @@ -269,19 +286,12 @@ private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void } if (! $hasConstructor) { - $ctorStmts = []; - if ($node->extends !== null) { - $ctorStmts[] = new Node\Stmt\If_( - new Node\Expr\FuncCall(new Node\Name('method_exists'), [ - new Node\Arg(new Node\Expr\ClassConstFetch(new Node\Name('parent'), 'class')), - new Node\Arg(new Node\Scalar\String_('__construct')), - ]), - ['stmts' => [ - new Node\Stmt\Expression(new Node\Expr\StaticCall(new Node\Name('parent'), '__construct', [new Node\Arg(new Node\Expr\Variable('_typephp_ctor_args'), false, true)])), - ]] - ); + if ($node->extends !== null || $node->isAbstract()) { + return; } + $ctorStmts = []; + foreach ($defaultProps as $dp) { $checkCall = NodeBuilder::createPropertyCheckCall( new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $dp['name']), @@ -295,7 +305,7 @@ private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void $ctor = new Node\Stmt\ClassMethod('__construct', [ 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC, - 'params' => [new Node\Param(new Node\Expr\Variable('_typephp_ctor_args'), null, null, false, true)], + 'params' => [], 'stmts' => $ctorStmts, ]); $ctor->setAttribute('typephp_injected', true); diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 3735121..8a0d45a 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -89,6 +89,13 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $appFileDecisionCache = []; + /** + * In-memory fast-path cache for paths identified as non-transformable templates or caches. + * + * @var array + */ + private static array $bypassedPathCache = []; + /** * Native PHP functions that read raw source code for viewing, highlighting, or tokenizing. * @@ -135,6 +142,7 @@ public static function reset(): void self::$statCache = []; self::$staticNegativeStatCache = []; self::$appFileDecisionCache = []; + self::$bypassedPathCache = []; PathMatcher::reset(); } @@ -322,7 +330,31 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ { $isInclude = ($options & self::STREAM_OPEN_FOR_INCLUDE) !== 0; - if (! $isInclude || ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') || ! str_ends_with(strtolower($path), '.php') || ! Config::isEnabled()) { + if (! $isInclude || ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') || ! Config::isEnabled()) { + return $this->openDirectHandle($path, $mode, $options); + } + + if (isset(self::$bypassedPathCache[$path])) { + return $this->openDirectHandle($path, $mode, $options); + } + + $lowerPath = strtolower($path); + + if (! str_ends_with($lowerPath, '.php')) { + return $this->openDirectHandle($path, $mode, $options); + } + + if ( + str_ends_with($lowerPath, '.view.php') + || str_ends_with($lowerPath, '.blade.php') + || str_ends_with($lowerPath, '.html.php') + || str_ends_with($lowerPath, '.phtml') + || str_contains($lowerPath, '/.tempest/') + || str_contains($lowerPath, '/cache/views/') + || str_contains($lowerPath, '/framework/views/') + ) { + self::$bypassedPathCache[$path] = true; + return $this->openDirectHandle($path, $mode, $options); } diff --git a/src/Internal/Util/FileFilter.php b/src/Internal/Util/FileFilter.php index d097eeb..ee5a7e7 100644 --- a/src/Internal/Util/FileFilter.php +++ b/src/Internal/Util/FileFilter.php @@ -41,8 +41,21 @@ public static function isFileExcluded(string|false|null $fileName): bool return self::$pathFilterCache[$normalizedPath]; } - // Non-PHP files are always excluded from PHPDoc contract processing - if (! str_ends_with(strtolower($normalizedPath), '.php')) { + $lower = strtolower($normalizedPath); + + if (! str_ends_with($lower, '.php')) { + return self::$pathFilterCache[$normalizedPath] = true; + } + + if ( + str_ends_with($lower, '.view.php') + || str_ends_with($lower, '.blade.php') + || str_ends_with($lower, '.html.php') + || str_ends_with($lower, '.phtml') + || str_contains($lower, '/.tempest/') + || str_contains($lower, '/cache/views/') + || str_contains($lower, '/framework/views/') + ) { return self::$pathFilterCache[$normalizedPath] = true; } diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index 3284012..aa42b9b 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -256,7 +256,8 @@ public static function isDynamicWritablePath(string $normalizedPath): bool return str_contains($canon, '/var/cache/') || str_starts_with($canon, 'var/cache/') || str_contains($canon, '/var/log/') || str_starts_with($canon, 'var/log/') || str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/') - || str_contains($canon, '/cache/') || str_starts_with($canon, 'cache/'); + || str_contains($canon, '/cache/') || str_starts_with($canon, 'cache/') + || str_contains($canon, '/.tempest/') || str_starts_with($canon, '.tempest/'); } /** @@ -265,6 +266,25 @@ public static function isDynamicWritablePath(string $normalizedPath): bool public static function mayPathBeIncluded(string $normalizedPath): bool { $canon = self::canonicalizePath($normalizedPath); + $lower = strtolower($canon); + + if ( + str_ends_with($lower, '.view.php') + || str_ends_with($lower, '.blade.php') + || str_ends_with($lower, '.html.php') + || str_ends_with($lower, '.phtml') + ) { + return false; + } + + if ( + str_contains($canon, '/.tempest/') + || str_starts_with($canon, '.tempest/') + || str_contains($canon, '/cache/views/') + || str_contains($canon, '/framework/views/') + ) { + return false; + } if (str_contains($canon, '/node_modules/') || str_starts_with($canon, 'node_modules/')) { return false; diff --git a/tests/Contract/ViewTemplateAndConstructorExclusionTest.php b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php new file mode 100644 index 0000000..c649a63 --- /dev/null +++ b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php @@ -0,0 +1,280 @@ +parser = (new ParserFactory())->createForNewestSupportedVersion(); + $this->printer = new TypePHPPrinter(); + }); + + afterEach(function () { + Config::reset(); + FileFilter::reset(); + PathMatcher::reset(); + StreamWrapper::reset(); + StreamWrapper::register(); + }); + + describe('1. View Template & Framework Cache Exclusions (FileFilter & PathMatcher)', function () { + test('FileFilter excludes view templates (.view.php, .blade.php, .html.php, .phtml)', function () { + $viewFiles = [ + '/app/views/user.view.php', + '/packages/view/src/Components/x-form.view.php', + '/resources/views/welcome.blade.php', + '/templates/layout.html.php', + '/templates/header.phtml', + ]; + + foreach ($viewFiles as $file) { + expect(FileFilter::isFileExcluded($file))->toBeTrue("Expected {$file} to be excluded"); + } + }); + + test('FileFilter excludes framework internal cache and compiled view directories', function () { + $frameworkCacheFiles = [ + '/home/user/project/.tempest/test_internal_storage/2/cache/views/d2690c032e1ea95f.php', + '/var/www/project/storage/framework/views/87587aadab5fcf86.php', + '/project/var/cache/views/compiled_view.php', + ]; + + foreach ($frameworkCacheFiles as $file) { + expect(FileFilter::isFileExcluded($file))->toBeTrue("Expected {$file} to be excluded"); + } + }); + + test('PathMatcher::mayPathBeIncluded returns false for view templates and framework caches', function () { + expect(PathMatcher::mayPathBeIncluded('/app/views/form.view.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/views/home.blade.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/.tempest/cache/views/abc.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/storage/framework/views/def.php'))->toBeFalse() + ; + }); + + test('standard PHP files remain included when view templates are bypassed', function () { + $projectRoot = Config::getProjectRoot(); + $standardPhpFile = str_replace('\\', '/', $projectRoot . '/src/Services/UserService.php'); + + expect(FileFilter::isFileExcluded($standardPhpFile))->toBeFalse(); + }); + }); + + describe('2. StreamWrapper Direct Handle Passthrough for Templates', function () { + test('bypasses AST transformation and serves raw view template content on include', function () { + $tempDir = sys_get_temp_dir() . '/typephp_view_test_' . uniqid(); + mkdir($tempDir, 0777, true); + $templateFile = $tempDir . '/x-button.view.php'; + + $rawTemplate = <<<'HTML' + + + + +HTML; + file_put_contents($templateFile, $rawTemplate); + + try { + $wrapper = new StreamWrapper(); + $openedPath = null; + + $opened = $wrapper->stream_open($templateFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + expect($opened)->toBeTrue(); + + $readContent = $wrapper->stream_read(10000); + $wrapper->stream_close(); + + expect($readContent)->toBe($rawTemplate) + ->and($readContent)->not()->toContain('hhref') + ->and($readContent)->not()->toContain('RuntimeTypeChecker') + ; + } finally { + @unlink($templateFile); + @rmdir($tempDir); + } + }); + + test('bypasses AST transformation for compiled view cache inside .tempest directory', function () { + $tempDir = sys_get_temp_dir() . '/.tempest/cache/views'; + mkdir($tempDir, 0777, true); + $compiledCacheFile = $tempDir . '/compiled_view_123.php'; + + $compiledContent = '"; };'; + file_put_contents($compiledCacheFile, $compiledContent); + + try { + $wrapper = new StreamWrapper(); + $openedPath = null; + + $opened = $wrapper->stream_open($compiledCacheFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + expect($opened)->toBeTrue(); + + $readContent = $wrapper->stream_read(10000); + $wrapper->stream_close(); + + expect($readContent)->toBe($compiledContent); + } finally { + @unlink($compiledCacheFile); + @rmdir($tempDir); + @rmdir(\dirname($tempDir)); + @rmdir(\dirname($tempDir, 2)); + } + }); + }); + + describe('3. Constructor Synthesis Safety (ContractVisitor)', function () { + test('does NOT inject __construct when class extends a parent class', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->not()->toContain('function __construct'); + }); + + test('does NOT inject __construct when class is abstract', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->not()->toContain('function __construct'); + }); + + test('injects __construct with zero parameters (params: []) on standalone concrete classes', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('function __construct()') + ->and($transformed)->not()->toContain('$_typephp_ctor_args') + ->and($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$this->retries, \$this, 'retries'") + ; + }); + + test('preserves and injects checks into existing constructors without altering signatures', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('function __construct(string $name, array $options = [])') + ->and($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$this->limit, \$this, 'limit'") + ; + }); + }); + + describe('4. Post-Increment Expression Semantics in Parsers/Iterators', function () { + test('preserves post-increment semantics inside array dimension lookup ($arr[$i++])', function () { + $source = <<<'PHP' +parser->parse($source); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('$data[$i++]') + ->and($transformed)->not()->toContain('$i = $i + 1') + ; + }); + + test('preserves post-increment on object properties inside array dimension ($arr[$this->cursor++])', function () { + $source = <<<'PHP' +']; + + public function consume(): string + { + return $this->buffer[$this->cursor++]; + } +} +PHP; + + $stmts = $this->parser->parse($source); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('$this->buffer[$this->cursor++]') + ->and($transformed)->not()->toContain('checkProperty($this->cursor + 1') + ; + }); + }); +}); From 316ab8a1e04f497a120466a2e4e553626f074f85 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 20:04:15 +0800 Subject: [PATCH 13/23] Refactor ViewTemplateAndConstructorExclusionTest to improve test isolationa and prevent failing ci 8.2 matrix --- ...iewTemplateAndConstructorExclusionTest.php | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/tests/Contract/ViewTemplateAndConstructorExclusionTest.php b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php index c649a63..d964c0c 100644 --- a/tests/Contract/ViewTemplateAndConstructorExclusionTest.php +++ b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php @@ -5,33 +5,13 @@ namespace TypePHP\Tests\Contract; use PhpParser\NodeTraverser; -use PhpParser\ParserFactory; use TypePHP\Internal\Ast\ContractVisitor; -use TypePHP\Internal\Ast\TypePHPPrinter; use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\FileFilter; use TypePHP\Internal\Util\PathMatcher; describe('View Template, Framework Cache & Constructor Synthesis Regression Tests', function () { - beforeEach(function () { - Config::reset(); - FileFilter::reset(); - PathMatcher::reset(); - StreamWrapper::reset(); - StreamWrapper::register(); - $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); - $this->printer = new TypePHPPrinter(); - }); - - afterEach(function () { - Config::reset(); - FileFilter::reset(); - PathMatcher::reset(); - StreamWrapper::reset(); - StreamWrapper::register(); - }); - describe('1. View Template & Framework Cache Exclusions (FileFilter & PathMatcher)', function () { test('FileFilter excludes view templates (.view.php, .blade.php, .html.php, .phtml)', function () { $viewFiles = [ @@ -148,12 +128,12 @@ class CustomTestCase extends \PHPUnit\Framework\TestCase } PHP; - $stmts = $this->parser->parse($code); + $stmts = StreamWrapper::getParser()->parse($code); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->not()->toContain('function __construct'); }); @@ -168,12 +148,12 @@ abstract class AbstractBaseModel } PHP; - $stmts = $this->parser->parse($code); + $stmts = StreamWrapper::getParser()->parse($code); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->not()->toContain('function __construct'); }); @@ -188,12 +168,12 @@ class StandaloneConfig } PHP; - $stmts = $this->parser->parse($code); + $stmts = StreamWrapper::getParser()->parse($code); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->toContain('function __construct()') ->and($transformed)->not()->toContain('$_typephp_ctor_args') @@ -216,12 +196,12 @@ public function __construct(string $name, array $options = []) } PHP; - $stmts = $this->parser->parse($code); + $stmts = StreamWrapper::getParser()->parse($code); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->toContain('function __construct(string $name, array $options = [])') ->and($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$this->limit, \$this, 'limit'") @@ -238,12 +218,12 @@ public function __construct(string $name, array $options = []) $result = $data[$i++]; PHP; - $stmts = $this->parser->parse($source); + $stmts = StreamWrapper::getParser()->parse($source); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->toContain('$data[$i++]') ->and($transformed)->not()->toContain('$i = $i + 1') @@ -265,12 +245,12 @@ public function consume(): string } PHP; - $stmts = $this->parser->parse($source); + $stmts = StreamWrapper::getParser()->parse($source); $traverser = new NodeTraverser(); $traverser->addVisitor(new ContractVisitor()); $newStmts = $traverser->traverse($stmts); - $transformed = $this->printer->prettyPrint($newStmts); + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); expect($transformed)->toContain('$this->buffer[$this->cursor++]') ->and($transformed)->not()->toContain('checkProperty($this->cursor + 1') From a4f2756387cfe60a9a3cade16eb126e1ada07248 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 9 Oct 2026 20:41:25 +0800 Subject: [PATCH 14/23] Enhance StreamWrapper and PathMatcher to improve path handling and bypass logic for PHP files --- src/Internal/Io/StreamWrapper.php | 19 +++++++++---------- src/Internal/Util/PathMatcher.php | 4 ++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 8a0d45a..085fea9 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -338,20 +338,19 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $this->openDirectHandle($path, $mode, $options); } - $lowerPath = strtolower($path); - - if (! str_ends_with($lowerPath, '.php')) { + $isPhp = str_ends_with($path, '.php') || str_ends_with(strtolower($path), '.php'); + if (! $isPhp) { return $this->openDirectHandle($path, $mode, $options); } if ( - str_ends_with($lowerPath, '.view.php') - || str_ends_with($lowerPath, '.blade.php') - || str_ends_with($lowerPath, '.html.php') - || str_ends_with($lowerPath, '.phtml') - || str_contains($lowerPath, '/.tempest/') - || str_contains($lowerPath, '/cache/views/') - || str_contains($lowerPath, '/framework/views/') + str_ends_with($path, '.view.php') + || str_ends_with($path, '.blade.php') + || str_ends_with($path, '.html.php') + || str_ends_with($path, '.phtml') + || str_contains($path, '/.tempest/') + || str_contains($path, '/cache/views/') + || str_contains($path, '/framework/views/') ) { self::$bypassedPathCache[$path] = true; diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index aa42b9b..a9b0d6a 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -161,6 +161,10 @@ public static function isStaticSourcePath(string $normalizedPath): bool return true; } + if (str_starts_with($canon, 'config/') || str_contains($canon, '/config/')) { + return true; + } + if (str_starts_with($canon, 'lib/') || str_contains($canon, '/lib/')) { return true; } From 50a4993a9bca42964d3780f0f8722adafe2f5b34 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 11:23:21 +0800 Subject: [PATCH 15/23] Enhance CacheManager and StreamWrapper to improve caching mechanisms and versioning --- src/Internal/Io/CacheManager.php | 18 +++++-- src/Internal/Io/StreamWrapper.php | 83 +++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/Internal/Io/CacheManager.php b/src/Internal/Io/CacheManager.php index a782013..4fc0b5b 100644 --- a/src/Internal/Io/CacheManager.php +++ b/src/Internal/Io/CacheManager.php @@ -15,7 +15,7 @@ final class CacheManager /** * Cache version prefix string. Bump this whenever AST printer/transformation rules change. */ - public const VERSION_PREFIX = 'v0.10.24_'; + public const VERSION_PREFIX = 'v0.11.4_'; /** * Cached absolute cache directory path. @@ -27,10 +27,18 @@ final class CacheManager */ private static ?bool $secureDirVerified = null; + /** + * In-memory cache for resolved cache keys: [$resolvedPath] => string + * + * @var array + */ + private static array $cacheKeyMap = []; + public static function reset(): void { self::$resolvedCacheDir = null; self::$secureDirVerified = null; + self::$cacheKeyMap = []; } /** @@ -72,14 +80,18 @@ public static function getCacheDir(): string */ public static function getCacheKey(string $resolvedPath): string { + if (isset(self::$cacheKeyMap[$resolvedPath])) { + return self::$cacheKeyMap[$resolvedPath]; + } + if (Config::isCacheCheckMtimeEnabled()) { $mtime = @filemtime($resolvedPath); $mtimeStr = $mtime !== false ? (string) $mtime : '0'; - return hash('xxh128', self::VERSION_PREFIX . $resolvedPath . $mtimeStr); + return self::$cacheKeyMap[$resolvedPath] = hash('xxh128', self::VERSION_PREFIX . $resolvedPath . $mtimeStr); } - return hash('xxh128', self::VERSION_PREFIX . $resolvedPath); + return self::$cacheKeyMap[$resolvedPath] = hash('xxh128', self::VERSION_PREFIX . $resolvedPath); } /** diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 085fea9..694dc64 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -76,7 +76,7 @@ final class StreamWrapper implements StreamWrapperInterface private static array $statCache = []; /** - * In-memory cache for static-path negative misses only (vendor & package source directories). + * In-memory cache for static-path negative misses only (vendor directories). * * @var array */ @@ -96,6 +96,15 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $bypassedPathCache = []; + /** + * In-memory cache for resolved positive realpath results: [$path] => string + * + * @var array + */ + private static array $realpathCache = []; + + private static array $verifiedCachedFiles = []; + /** * Native PHP functions that read raw source code for viewing, highlighting, or tokenizing. * @@ -143,6 +152,8 @@ public static function reset(): void self::$staticNegativeStatCache = []; self::$appFileDecisionCache = []; self::$bypassedPathCache = []; + self::$realpathCache = []; + self::$verifiedCachedFiles = []; PathMatcher::reset(); } @@ -338,11 +349,13 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $this->openDirectHandle($path, $mode, $options); } + // Fast-path: checks .php directly on $path first, avoiding strtolower() on 99.99% of classes $isPhp = str_ends_with($path, '.php') || str_ends_with(strtolower($path), '.php'); if (! $isPhp) { return $this->openDirectHandle($path, $mode, $options); } + // Fast-path: check template and view caches directly without strtolower() if ( str_ends_with($path, '.view.php') || str_ends_with($path, '.blade.php') @@ -368,10 +381,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(static fn () => file_exists($path)); - $resolvedPath = $exists ? self::silent(static fn () => realpath($path)) : false; + if (isset(self::$realpathCache[$path])) { + $resolvedPath = self::$realpathCache[$path]; + } else { + $resolvedPath = self::silent(static fn () => realpath($path)); + if ($resolvedPath !== false) { + self::$realpathCache[$path] = $resolvedPath; + } + } + + if ($resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { + if ($resolvedPath !== false) { + self::$bypassedPathCache[$path] = true; + } - if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( @@ -614,17 +637,19 @@ public function url_stat(string $path, int $flags): array|false self::register(); if ($result !== false) { - $isPhp = str_ends_with(strtolower($normalized), '.php'); - $isStaticDir = is_dir($path) && PathMatcher::isStaticSourcePath($normalized); + $isPhp = str_ends_with($normalized, '.php') || str_ends_with(strtolower($normalized), '.php'); + $isStatic = PathMatcher::isStaticSourcePath($normalized); - if ($isPhp || PathMatcher::isVendorPath($normalized) || $isStaticDir) { + // Caches all existing PHP files, vendor files, and static files + if ($isPhp || PathMatcher::isVendorPath($normalized) || $isStatic) { self::$statCache[$cacheKey] = $result; } return $result; } - if (PathMatcher::isStaticSourcePath($normalized)) { + // Only cache negative misses for immutable vendor packages to prevent poisoning application paths + if (PathMatcher::isVendorPath($normalized)) { self::$staticNegativeStatCache[$normalized] = true; } @@ -637,7 +662,9 @@ public function stream_metadata(string $path, int $option, mixed $value): bool unset( self::$statCache[$normalized . ':stat'], self::$statCache[$normalized . ':lstat'], - self::$staticNegativeStatCache[$normalized] + self::$staticNegativeStatCache[$normalized], + self::$realpathCache[$path], + self::$bypassedPathCache[$path] ); self::unregister(); @@ -709,7 +736,9 @@ public function mkdir(string $path, int $mode, int $options): bool unset( self::$statCache[$normalized . ':stat'], self::$statCache[$normalized . ':lstat'], - self::$staticNegativeStatCache[$normalized] + self::$staticNegativeStatCache[$normalized], + self::$realpathCache[$path], + self::$bypassedPathCache[$path] ); self::unregister(); @@ -727,7 +756,9 @@ public function rmdir(string $path, int $options): bool unset( self::$statCache[$normalized . ':stat'], self::$statCache[$normalized . ':lstat'], - self::$staticNegativeStatCache[$normalized] + self::$staticNegativeStatCache[$normalized], + self::$realpathCache[$path], + self::$bypassedPathCache[$path] ); self::unregister(); @@ -745,7 +776,9 @@ public function unlink(string $path): bool unset( self::$statCache[$normalized . ':stat'], self::$statCache[$normalized . ':lstat'], - self::$staticNegativeStatCache[$normalized] + self::$staticNegativeStatCache[$normalized], + self::$realpathCache[$path], + self::$bypassedPathCache[$path] ); self::unregister(); @@ -765,9 +798,13 @@ public function rename(string $pathFrom, string $pathTo): bool self::$statCache[$normFrom . ':stat'], self::$statCache[$normFrom . ':lstat'], self::$staticNegativeStatCache[$normFrom], + self::$realpathCache[$pathFrom], + self::$bypassedPathCache[$pathFrom], self::$statCache[$normTo . ':stat'], self::$statCache[$normTo . ':lstat'], - self::$staticNegativeStatCache[$normTo] + self::$staticNegativeStatCache[$normTo], + self::$realpathCache[$pathTo], + self::$bypassedPathCache[$pathTo] ); self::unregister(); @@ -870,15 +907,19 @@ private function openCachedStream(string $resolvedPath, string $mode): bool return $this->openMemoryStream($resolvedPath); } - if (! file_exists($cachedFile) || is_link($cachedFile)) { - $source = file_get_contents($resolvedPath); - if ($source === false) { - return false; - } - $transformed = self::transformSource($source, $resolvedPath); - if (! CacheManager::writeCachedFileSafely($cachedFile, $transformed)) { - return $this->openMemoryStream($resolvedPath); + if (! isset(self::$verifiedCachedFiles[$cachedFile])) { + if (! file_exists($cachedFile) || is_link($cachedFile)) { + $source = file_get_contents($resolvedPath); + if ($source === false) { + return false; + } + $transformed = self::transformSource($source, $resolvedPath); + if (! CacheManager::writeCachedFileSafely($cachedFile, $transformed)) { + return $this->openMemoryStream($resolvedPath); + } } + + self::$verifiedCachedFiles[$cachedFile] = true; } $cacheHandle = ($this->context !== null) From b2c8bdb3fac5affa2a79c83c0eafe738393fb437 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 11:27:46 +0800 Subject: [PATCH 16/23] fix phpstan errors --- src/Internal/Io/StreamWrapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 694dc64..a9c2712 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -103,6 +103,11 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $realpathCache = []; + /** + * In-memory cache for verified cached files: [$path] => true + * + * @var array + */ private static array $verifiedCachedFiles = []; /** From 47c6751e7ffd793e26e7c9698a32e457d7ffc554 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 12:10:47 +0800 Subject: [PATCH 17/23] Fix bug on self:: in an imported @phpstan-type alias is resolved against the importing class --- src/Internal/Docblock/DocblockExtractor.php | 6 + src/Internal/Resolver/SpecialTypeResolver.php | 13 +- .../ImportedTypeAliasSelfResolutionTest.php | 154 ++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php diff --git a/src/Internal/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index 55e1d9a..cc780f6 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -563,6 +563,12 @@ public static function extractAliases( foreach ($aliases as $name => $type) { $aliases[$name] = DocblockParser::substituteAliases($type, $aliases); } + + foreach ($currentDocAliases as $name => $_) { + if (isset($aliases[$name])) { + $aliases[$name] = SpecialTypeResolver::resolve($aliases[$name], $ref); + } + } } /** diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index c151eac..4449ddf 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -531,6 +531,17 @@ private static function resolveIdentifier( } } + if (str_starts_with($lower, 'self::') && $declaringClass !== null) { + return new IdentifierTypeNode($declaringClass . substr($node->name, 4)); + } + + if (str_starts_with($lower, 'parent::') && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + if ($parentClass !== false) { + return new IdentifierTypeNode($parentClass . substr($node->name, 6)); + } + } + $fqcn = self::resolveFqcn($node->name, $ref); return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : $node; @@ -617,7 +628,7 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass if ($className !== null && $constName !== null) { $lowerClassName = strtolower($className); - $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : null; + $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : ($ref instanceof \ReflectionClass ? $ref->getName() : null); if ($lowerClassName === 'self' && $declaringClass !== null) { $resolvedClass = $declaringClass; diff --git a/tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php b/tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php new file mode 100644 index 0000000..862517e --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php @@ -0,0 +1,154 @@ + + */ +final class RankingFixture +{ + public const STATE_FOUND = 'found'; + public const STATE_UNKNOWN = 'unknown'; + + public const MAP = [ + 'rank_a' => 1, + 'rank_b' => 2, + ]; +} + +/** + * Intermediary class for testing chained imports: + * RankingFixture -> ChainedImportMidFixture -> ChainedImportConsumerFixture + * + * @phpstan-import-type Cell from RankingFixture as MidCell + * + * @phpstan-type ExportedChainedCell MidCell + */ +final class ChainedImportMidFixture +{ +} + +/** + * @phpstan-import-type Cell from RankingFixture + * @phpstan-import-type SpecificCell from RankingFixture + * @phpstan-import-type ConfigMap from RankingFixture + */ +final class SearchFixture +{ + /** + * Exact scenario from the bug report: @return Cell with self::STATE_* + * + * @return Cell + */ + public function cell(string $state = RankingFixture::STATE_FOUND, float $value = 1.0): array + { + return ['state' => $state, 'value' => $value]; + } + + /** + * Parameter contract using the imported alias + * + * @param Cell $cell + */ + public function acceptCell(array $cell): bool + { + return true; + } + + /** + * Specific constant alias: self::STATE_FOUND + * + * @return SpecificCell + */ + public function specificCell(): array + { + return ['state' => RankingFixture::STATE_FOUND, 'value' => 2.5]; + } + + /** + * key-of with self:: + * + * @param ConfigMap $key + */ + public function acceptConfigKey(string $key): bool + { + return true; + } +} + +/** + * Consumer testing chained alias imports + * + * @phpstan-import-type ExportedChainedCell from ChainedImportMidFixture + */ +final class ChainedImportConsumerFixture +{ + /** + * @return ExportedChainedCell + */ + public function getCell(): array + { + return ['state' => RankingFixture::STATE_UNKNOWN, 'value' => 0.5]; + } +} + +describe('Imported Type Alias Self Resolution (@phpstan-import-type with self::)', function () { + test('resolves self::PREFIX_* in imported alias to the declaring class for return types (Bug Report Reproduction)', function () { + $search = new SearchFixture(); + + $result = $search->cell(); + + expect($result)->toBe([ + 'state' => 'found', + 'value' => 1.0, + ]); + }); + + test('resolves self::PREFIX_* in imported alias to the declaring class for parameter types', function () { + $search = new SearchFixture(); + + expect($search->acceptCell(['state' => RankingFixture::STATE_UNKNOWN, 'value' => 3.14]))->toBeTrue(); + + expect(fn () => $search->acceptCell(['state' => 'invalid_state', 'value' => 1.0])) + ->toThrow(TypeError::class, 'must be a valid constant matching') + ; + }); + + test('resolves specific self::CONSTANT in imported alias to the declaring class', function () { + $search = new SearchFixture(); + + $result = $search->specificCell(); + + expect($result)->toBe([ + 'state' => 'found', + 'value' => 2.5, + ]); + }); + + test('resolves key-of in imported alias to the declaring class', function () { + $search = new SearchFixture(); + + expect($search->acceptConfigKey('rank_a'))->toBeTrue(); + + expect(fn () => $search->acceptConfigKey('invalid_rank')) + ->toThrow(TypeError::class, 'must be a key of') + ; + }); + + test('resolves self:: in multi-tier chained imported aliases (A -> B -> C)', function () { + $consumer = new ChainedImportConsumerFixture(); + + $result = $consumer->getCell(); + + expect($result)->toBe([ + 'state' => 'unknown', + 'value' => 0.5, + ]); + }); +}); From eeb1c8c08bf508d9fee5c15b07c3cc053d238fce Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 12:53:56 +0800 Subject: [PATCH 18/23] Refactor InlineChecker and ReturnChecker to utilize parseClassTemplates method; add DocblockParser::parseClassTemplates for improved template extraction; introduce InheritedConstructorClassTemplateTest for bug reproduction and validation --- src/Internal/Checker/InlineChecker.php | 30 +++- src/Internal/Checker/ReturnChecker.php | 11 +- src/Internal/Docblock/DocblockParser.php | 36 +++- .../InheritedConstructorClassTemplateTest.php | 158 ++++++++++++++++++ 4 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 tests/TypeChecking/Generics/InheritedConstructorClassTemplateTest.php diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index e89a7f4..4794a4c 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -484,9 +484,13 @@ private static function propertyUsesTemplates(TypeNode $rawTypeNode, string $cla return self::$propertyUsesTemplatesCache[$className][$propName]; } - $constructorTarget = $className . '::__construct'; - $contract = DocblockParser::parse($constructorTarget); - $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $allTemplates = DocblockParser::parseClassTemplates($className); + + if ($allTemplates === []) { + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + } if ($allTemplates === []) { return self::$propertyUsesTemplatesCache[$className][$propName] = false; @@ -737,10 +741,14 @@ private static function resolveClassContext( */ private static function substitutePropertyGenerics(TypeNode $typeNode, object $object, string $className): TypeNode { - $constructorTarget = $className . '::__construct'; - $contract = DocblockParser::parse($constructorTarget); + $allTemplates = DocblockParser::parseClassTemplates($className); + + if ($allTemplates === []) { + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + } - $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; $boundTemplates = TemplateManager::getBoundTemplates('none', $object, $allTemplates); $declaredTemplates = $allTemplates; @@ -770,10 +778,14 @@ private static function substituteStaticPropertyGenerics(TypeNode $typeNode, str return self::$resolvedStaticPropertyTypeCache[$className][$propName]; } - $constructorTarget = $className . '::__construct'; - $contract = DocblockParser::parse($constructorTarget); + $allTemplates = DocblockParser::parseClassTemplates($className); + + if ($allTemplates === []) { + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + } - $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; $classBindings = TemplateManager::getClassInheritedBindings($className); $classAliases = DocblockParser::parseClassAliases($className); $activeBindings = [...$classAliases, ...$classBindings]; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index e8939e6..b6eb000 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -223,9 +223,14 @@ private static function handleMagicPropertyRead( $typeNode = $magicContract['readType']; if ($thisObj !== null) { - $constructorTarget = $className . '::__construct'; - $contract = DocblockParser::parse($constructorTarget); - $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $allTemplates = DocblockParser::parseClassTemplates($className); + + if ($allTemplates === []) { + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + } + $boundTemplates = TemplateManager::getBoundTemplates('none', $thisObj, $allTemplates); if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 19bca34..17175ab 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -359,7 +359,7 @@ public static function parse(string $function): array $refClass = new \ReflectionClass($className); if ($refClass->hasMethod($methodName)) { $ref = $refClass->getMethod($methodName); - $contract = self::parseMethod($ref); + $contract = self::parseMethod($ref, $refClass); } else { $classTemplates = []; $aliases = []; @@ -443,6 +443,34 @@ public static function parse(string $function): array return self::$cache[$function] = $contract; } + /** + * Extracts and returns all class-level templates for a given class. + * + * @return array + */ + public static function parseClassTemplates(string $className): array + { + if (isset(self::$classLevelDocCache[$className])) { + return self::$classLevelDocCache[$className]['templates']; + } + + if (! class_exists($className, false) && ! class_exists($className) && ! interface_exists($className, false) && ! interface_exists($className) && ! trait_exists($className, false) && ! trait_exists($className) && ! enum_exists($className, false) && ! enum_exists($className)) { + return []; + } + + try { + /** @var class-string $className */ + $refClass = new \ReflectionClass($className); + $aliases = []; + $classTemplates = []; + self::parseClassLevelDocs($refClass, $classTemplates, $aliases); + + return $classTemplates; + } catch (\Throwable $e) { + return []; + } + } + /** * Parses and resolves the @var or @property docblock for a given class property with zero-allocation 2D caching. */ @@ -904,9 +932,11 @@ public static function parseClassAliases(string $className): array /** * Orchestrates parsing for class methods across the inheritance hierarchy. * + * @param \ReflectionClass|null $targetClass + * * @return FunctionContract */ - private static function parseMethod(\ReflectionMethod $ref): array + private static function parseMethod(\ReflectionMethod $ref, ?\ReflectionClass $targetClass = null): array { /** @var array $types */ $types = []; @@ -923,7 +953,7 @@ private static function parseMethod(\ReflectionMethod $ref): array /** @var array $sensitiveParams */ $sensitiveParams = []; - $targetClass = (class_exists($ref->class, false) || class_exists($ref->class) || interface_exists($ref->class) || enum_exists($ref->class) || trait_exists($ref->class)) + $targetClass ??= (class_exists($ref->class, false) || class_exists($ref->class) || interface_exists($ref->class) || enum_exists($ref->class) || trait_exists($ref->class)) ? new \ReflectionClass($ref->class) : $ref->getDeclaringClass(); diff --git a/tests/TypeChecking/Generics/InheritedConstructorClassTemplateTest.php b/tests/TypeChecking/Generics/InheritedConstructorClassTemplateTest.php new file mode 100644 index 0000000..7619735 --- /dev/null +++ b/tests/TypeChecking/Generics/InheritedConstructorClassTemplateTest.php @@ -0,0 +1,158 @@ + + */ +abstract class ReproInheritedCtorInputField extends ReproInheritedCtorField +{ +} + +/** + * Leaf class with inherited constructor from ReproInheritedCtorBase + */ +final class ReproInheritedCtorTextField extends ReproInheritedCtorInputField +{ +} + +/** + * Specialized leaf class binding TEntity to Dog with inherited constructor + * + * @extends ReproInheritedCtorField + */ +final class ReproInheritedCtorDogField extends ReproInheritedCtorField +{ +} + +/** + * Strict non-nullable static property fixture + * + * @template TEntity of object + */ +abstract class ReproStaticStrictHolder extends ReproInheritedCtorBase +{ + /** + * @var TEntity + */ + public static object $strictStaticEntity; +} + +/** + * @extends ReproStaticStrictHolder + */ +final class ReproStaticStrictDogHolder extends ReproStaticStrictHolder +{ +} + +describe('Inherited Constructor Class Template Property Assignment (Bug Report Reproduction)', function () { + beforeEach(function () { + ReproInheritedCtorTextField::$staticEntity = null; + ReproInheritedCtorDogField::$staticEntity = null; + }); + + afterEach(function () { + ReproInheritedCtorTextField::$staticEntity = null; + ReproInheritedCtorDogField::$staticEntity = null; + }); + + describe('Instance Property Assignments', function () { + test('allows assigning any value to unbound generic template property when constructor is inherited from untemplated base', function () { + $field = new ReproInheritedCtorTextField(); + + $field->entity = new stdClass(); + expect($field->entity)->toBeInstanceOf(stdClass::class); + + $field->entity = 'sample_string'; + expect($field->entity)->toBe('sample_string'); + + $field->entity = 12345; + expect($field->entity)->toBe(12345); + }); + + test('enforces specialized bound template when subclass extends templated parent with inherited constructor', function () { + $dogField = new ReproInheritedCtorDogField(); + + $dog = new Dog(); + $dogField->entity = $dog; + expect($dogField->entity)->toBe($dog); + + expect(fn () => $dogField->entity = new Car()) + ->toThrow(TypeError::class, Dog::class) + ; + }); + }); + + describe('Static Property Assignments', function () { + test('allows assigning any value to unbound generic template static property with inherited constructor', function () { + try { + ReproInheritedCtorTextField::$staticEntity = new stdClass(); + expect(ReproInheritedCtorTextField::$staticEntity)->toBeInstanceOf(stdClass::class); + + ReproInheritedCtorTextField::$staticEntity = 'static_sample_string'; + expect(ReproInheritedCtorTextField::$staticEntity)->toBe('static_sample_string'); + + ReproInheritedCtorTextField::$staticEntity = 99999; + expect(ReproInheritedCtorTextField::$staticEntity)->toBe(99999); + } finally { + ReproInheritedCtorTextField::$staticEntity = null; + } + }); + + test('enforces specialized bound template when assigning to nullable static property on subclass with inherited constructor', function () { + try { + $dog = new Dog(); + ReproInheritedCtorDogField::$staticEntity = $dog; + expect(ReproInheritedCtorDogField::$staticEntity)->toBe($dog); + + expect(fn () => ReproInheritedCtorDogField::$staticEntity = new Car()) + ->toThrow(TypeError::class, '(TypePHP\Tests\Fixtures\Domain\Dog | null)') + ; + } finally { + ReproInheritedCtorDogField::$staticEntity = null; + } + }); + + test('enforces strict non-nullable specialized bound template on static property with inherited constructor', function () { + $dog = new Dog(); + ReproStaticStrictDogHolder::$strictStaticEntity = $dog; + expect(ReproStaticStrictDogHolder::$strictStaticEntity)->toBe($dog); + + expect(fn () => ReproStaticStrictDogHolder::$strictStaticEntity = new Car()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + }); +}); From 8aedb0f55bf57d6939c6c5f95e3b4d0427e86690 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 13:04:40 +0800 Subject: [PATCH 19/23] Add tests for subclass type alias in inherited tags to validate behavior and implement green fix --- src/Internal/Generics/TemplateManager.php | 4 + .../SubclassAliasInInheritedTagTest.php | 128 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index aa907e8..de35ad3 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -1134,10 +1134,14 @@ private static function collectInheritedGenericTagBindings( $parentTemplateNames = array_keys(DocblockExtractor::extractTemplates($parentPhpDocNode)); $parentTemplateNodes = array_values(DocblockExtractor::extractTemplates($parentPhpDocNode)); $normalizedGenericTypes = self::normalizeGenericArguments($genericTypeNode->genericTypes, $parentTemplateNodes); + $hierAliases = DocblockParser::parseClassAliases($hierClass->getName()); foreach ($parentTemplateNames as $idx => $templateName) { if (isset($normalizedGenericTypes[$idx])) { $typeArg = $normalizedGenericTypes[$idx]; + if ($hierAliases !== []) { + $typeArg = DocblockParser::substituteAliases($typeArg, $hierAliases); + } if ($bindings !== []) { $typeArg = TemplateSubstitutor::substitute($typeArg, $bindings); } diff --git a/tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php b/tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php new file mode 100644 index 0000000..00cc805 --- /dev/null +++ b/tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php @@ -0,0 +1,128 @@ +fetch(); + } + + /** + * Abstract method returning template TOutput + * + * @return TOutput + */ + abstract public function fetch(): array; +} + +/** + * @phpstan-type Row array{user_id: int, total: float} + * + * @extends ReproInheritedQueryBase> + */ +final class ReproTotalsQuery extends ReproInheritedQueryBase +{ + /** + * Inherits @return TOutput from parent without local docblock + */ + public function fetch(): array + { + return [ + ['user_id' => 1, 'total' => 2.5], + ['user_id' => 2, 'total' => 10.0], + ]; + } +} + +/** + * @phpstan-type InvalidRow array{user_id: positive-int, total: float} + * + * @extends ReproInheritedQueryBase> + */ +final class ReproInvalidTotalsQuery extends ReproInheritedQueryBase +{ + public function fetch(): array + { + return [ + ['user_id' => -1, 'total' => 2.5], + ]; + } +} + +/** + * @template TResult + */ +interface ReproResultInterface +{ + /** + * @return TResult + */ + public function getResult(): array; +} + +/** + * @phpstan-type UserPayload array{id: positive-int, name: non-empty-string} + * + * @implements ReproResultInterface + */ +final class ReproUserResultService implements ReproResultInterface +{ + public function getResult(): array + { + return ['id' => 42, 'name' => 'Alice']; + } +} + +describe('Subclass Type Alias in Inherited Tag (@extends and @implements with @phpstan-type)', function () { + test('expands subclass type alias used in @extends tag when parent method returns template (Bug Report Reproduction)', function () { + $query = new ReproTotalsQuery(); + + $result = $query->run(); + + expect($result)->toBe([ + ['user_id' => 1, 'total' => 2.5], + ['user_id' => 2, 'total' => 10.0], + ]); + }); + + test('expands subclass type alias when calling inherited method directly on subclass', function () { + $query = new ReproTotalsQuery(); + + $result = $query->fetch(); + + expect($result)->toBe([ + ['user_id' => 1, 'total' => 2.5], + ['user_id' => 2, 'total' => 10.0], + ]); + }); + + test('still enforces the alias shape constraints when values violate the expanded type', function () { + $invalidQuery = new ReproInvalidTotalsQuery(); + + expect(fn () => $invalidQuery->run()) + ->toThrow(TypeError::class, "['user_id'] must be of type positive-int") + ; + }); + + test('expands type alias used in @implements tag on interface methods', function () { + $service = new ReproUserResultService(); + + $result = $service->getResult(); + + expect($result)->toBe(['id' => 42, 'name' => 'Alice']); + }); +}); From c147982dfa6036b29a5ea40bac23c1fe2d8aaba6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 13:26:02 +0800 Subject: [PATCH 20/23] Implement class template extraction in ContractVisitor and add tests for templated property defaults in subclasses and fix bug on Inherited property default is checked against the subclass's template binding, so constructing the subclass throws (PHPStan accepts it) --- src/Internal/Ast/ContractVisitor.php | 46 ++++++++++ .../TemplatedPropertyDefaultSubclassTest.php | 88 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index d834687..d6e600b 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; use TypePHP\Internal\Docblock\DocblockExtractor; use TypePHP\Internal\Docblock\DocblockNormalizer; +use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Util\Config; /** @@ -258,6 +259,35 @@ private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void $defaultProps = []; $hasConstructor = false; + $classTemplates = []; + $nodeDoc = $node->getDocComment(); + if ($nodeDoc !== null && (str_contains($nodeDoc->getText(), '@template') || str_contains($nodeDoc->getText(), '@phpstan-template') || str_contains($nodeDoc->getText(), '@psalm-template'))) { + try { + $phpDocNode = DocblockExtractor::parseDocString($nodeDoc->getText()); + $classTemplates = DocblockExtractor::extractTemplates($phpDocNode); + } catch (\Throwable) { + } + } + + if ($node->name !== null) { + $className = $this->resolveQualifiedName($node->name); + if ($className !== null) { + try { + $loadedTemplates = DocblockParser::parseClassTemplates($className); + $classTemplates = [...$classTemplates, ...$loadedTemplates]; + } catch (\Throwable) { + } + } + } + + if ($node->extends !== null) { + try { + $parentTemplates = DocblockParser::parseClassTemplates($node->extends->toString()); + $classTemplates = [...$classTemplates, ...$parentTemplates]; + } catch (\Throwable) { + } + } + foreach ($node->stmts as $stmt) { if ($stmt instanceof Node\Stmt\ClassMethod && strtolower($stmt->name->toString()) === '__construct') { $hasConstructor = true; @@ -268,6 +298,22 @@ private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void $doc = $stmt->getDocComment(); if ($doc !== null && str_contains($doc->getText(), '@var') && ! str_contains($doc->getText(), '@typephp-ignore')) { + if ($classTemplates !== []) { + $varTag = DocblockExtractor::extractVarTagFromDoc($doc->getText()); + if ($varTag !== null) { + try { + [$typeParser, $lexer] = DocblockExtractor::getTypeParserComponents(); + $tokens = new TokenIterator($lexer->tokenize(DocblockNormalizer::normalize($varTag[0]))); + $propTypeNode = $typeParser->parse($tokens); + + if (DocblockParser::typeReferencesTemplate($propTypeNode, $classTemplates)) { + continue; + } + } catch (\Throwable) { + } + } + } + foreach ($stmt->props as $p) { $isExplicitNull = $p->default instanceof Node\Expr\ConstFetch && strtolower($p->default->name->toString()) === 'null'; if ($p->default !== null && ! $isExplicitNull) { diff --git a/tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php b/tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php new file mode 100644 index 0000000..1f23cf7 --- /dev/null +++ b/tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php @@ -0,0 +1,88 @@ + + */ +abstract class ReproTemplatedDefaultCommand +{ + /** + * @var TOptions + */ + protected array $options = []; + + public function __construct() + { + } + + /** + * @param TOptions $options + */ + public function init(array $options): static + { + $this->options = $options; + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } +} + +/** + * Subclass specializing TOptions with a required shape key + * + * @extends ReproTemplatedDefaultCommand + */ +final class ReproTemplatedDefaultGetAgency extends ReproTemplatedDefaultCommand +{ +} + +/** + * Control fixture: Non-templated class with an actually invalid default + */ +class ReproNonTemplatedInvalidDefaultFixture +{ + /** + * @var positive-int + */ + public int $count = -1; + + public function __construct() + { + } +} + +describe('Templated Property Default Value in Subclass Instantiation (Bug Report Reproduction)', function () { + test('allows constructing subclass when base property default is [] for template TOptions (Bug Report Reproduction)', function () { + $agency = new ReproTemplatedDefaultGetAgency(); + + expect($agency)->toBeInstanceOf(ReproTemplatedDefaultGetAgency::class) + ->and($agency->getOptions())->toBe([]) + ; + }); + + test('still enforces subclass shape contract when init() or assignment runs after construction', function () { + $agency = new ReproTemplatedDefaultGetAgency(); + + $agency->init(['name' => 'Agency One']); + expect($agency->getOptions())->toBe(['name' => 'Agency One']); + + expect(fn () => $agency->init(['invalid_key' => 123])) + ->toThrow(TypeError::class, "is missing required key 'name'") + ; + }); + + test('still enforces default value checks on non-templated properties with invalid defaults', function () { + expect(fn () => new ReproNonTemplatedInvalidDefaultFixture()) + ->toThrow(TypeError::class, 'must be of type positive-int') + ; + }); +}); From 3606c9290ca184bf441399ec3c2e0d71ff5eb9ff Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 13:59:05 +0800 Subject: [PATCH 21/23] Enhance project root resolution in Config class and add comprehensive tests for custom vendor-dir scenarios --- src/Internal/Util/Config.php | 65 +++++-- tests/Unit/ProjectRootCustomVendorDirTest.php | 183 ++++++++++++++++++ 2 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/ProjectRootCustomVendorDirTest.php diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index c6db5c1..4cac4ab 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -452,15 +452,34 @@ public static function getProjectRoot(?string $startingDir = null): string return self::$projectRoot; } + if ($startingDir === null && class_exists(\Composer\InstalledVersions::class)) { + try { + $rootPackage = \Composer\InstalledVersions::getRootPackage(); + $installPath = $rootPackage['install_path'] ?? null; + if (\is_string($installPath) && $installPath !== '') { + $realPath = realpath($installPath); + $normPath = rtrim(str_replace('\\', '/', $realPath !== false ? $realPath : $installPath), '/'); + if (file_exists($normPath . '/composer.json') || file_exists($normPath . '/typephp.php')) { + return self::$projectRoot = $normPath; + } + } + } catch (\Throwable) { + } + } + $cwd = getcwd(); if ($startingDir === null && $cwd !== false) { - $realCwd = realpath($cwd) !== false ? realpath($cwd) : $cwd; - $normCwd = rtrim(str_replace('\\', '/', (string) $realCwd), '/'); - if ( - file_exists($normCwd . '/vendor/autoload.php') - || file_exists($normCwd . '/composer.json') - ) { - return self::$projectRoot = $normCwd; + $realCwd = realpath($cwd); + $checkCwd = rtrim(str_replace('\\', '/', $realCwd !== false ? $realCwd : $cwd), '/'); + for ($i = 0; $i < 5; $i++) { + if (file_exists($checkCwd . '/composer.json') || file_exists($checkCwd . '/typephp.php')) { + return self::$projectRoot = $checkCwd; + } + $parent = \dirname($checkCwd); + if ($parent === $checkCwd) { + break; + } + $checkCwd = $parent; } } @@ -470,19 +489,26 @@ public static function getProjectRoot(?string $startingDir = null): string $vendorPos = strrpos($dir, '/vendor/'); if ($vendorPos !== false) { $candidate = substr($dir, 0, $vendorPos); - $realCandidate = realpath($candidate) !== false ? realpath($candidate) : $candidate; - $normCandidate = rtrim(str_replace('\\', '/', (string) $realCandidate), '/'); + $realCandidate = realpath($candidate); + $normCandidate = rtrim(str_replace('\\', '/', $realCandidate !== false ? $realCandidate : $candidate), '/'); if (file_exists($normCandidate . '/vendor/autoload.php') || file_exists($normCandidate . '/composer.json')) { return self::$projectRoot = $normCandidate; } } } - for ($i = 0; $i < 10; $i++) { - if (file_exists($dir . '/composer.json') || file_exists($dir . '/vendor/autoload.php')) { - $realDir = realpath($dir) !== false ? realpath($dir) : $dir; + $firstCandidate = null; - return self::$projectRoot = rtrim(str_replace('\\', '/', (string) $realDir), '/'); + for ($i = 0; $i < 15; $i++) { + if (file_exists($dir . '/composer.json') || file_exists($dir . '/typephp.php') || file_exists($dir . '/vendor/autoload.php')) { + $realDir = realpath($dir); + $normFound = rtrim(str_replace('\\', '/', $realDir !== false ? $realDir : $dir), '/'); + + if (str_ends_with($normFound, '/typephp/typephp')) { + $firstCandidate ??= $normFound; + } else { + return self::$projectRoot = $normFound; + } } $parent = \dirname($dir); @@ -492,9 +518,18 @@ public static function getProjectRoot(?string $startingDir = null): string $dir = $parent; } - $fallback = $cwd !== false ? (realpath($cwd) !== false ? realpath($cwd) : $cwd) : '.'; + if ($firstCandidate !== null) { + return self::$projectRoot = $firstCandidate; + } + + if ($cwd !== false) { + $realCwd = realpath($cwd); + $fallback = $realCwd !== false ? $realCwd : $cwd; + } else { + $fallback = '.'; + } - return self::$projectRoot = rtrim(str_replace('\\', '/', (string) $fallback), '/'); + return self::$projectRoot = rtrim(str_replace('\\', '/', $fallback), '/'); } /** diff --git a/tests/Unit/ProjectRootCustomVendorDirTest.php b/tests/Unit/ProjectRootCustomVendorDirTest.php new file mode 100644 index 0000000..2b3b001 --- /dev/null +++ b/tests/Unit/ProjectRootCustomVendorDirTest.php @@ -0,0 +1,183 @@ + 'acme/my-web-app', + 'config' => ['vendor-dir' => 'libs'], + 'extra' => [ + 'typephp' => [ + 'auto-boot' => false, + ], + ], + ]); + file_put_contents($tempBase . '/composer.json', $rootComposerJson); + + $libComposerJson = json_encode([ + 'name' => 'typephp/typephp', + ]); + file_put_contents($tempBase . '/libs/typephp/typephp/composer.json', $libComposerJson); + + $simulatedLibDir = $tempBase . '/libs/typephp/typephp/src/Internal/Util'; + + try { + Config::reset(); + + $resolvedRoot = Config::getProjectRoot($simulatedLibDir); + $normalizedExpected = rtrim(str_replace('\\', '/', (string) realpath($tempBase)), '/'); + + expect($resolvedRoot)->toBe($normalizedExpected); + } finally { + @unlink($tempBase . '/libs/typephp/typephp/composer.json'); + @unlink($tempBase . '/composer.json'); + @rmdir($tempBase . '/libs/typephp/typephp/src/Internal/Util'); + @rmdir($tempBase . '/libs/typephp/typephp/src/Internal'); + @rmdir($tempBase . '/libs/typephp/typephp/src'); + @rmdir($tempBase . '/libs/typephp/typephp'); + @rmdir($tempBase . '/libs/typephp'); + @rmdir($tempBase . '/libs'); + @rmdir($tempBase . '/public'); + @rmdir($tempBase); + Config::reset(); + } + }); + + test('uses Composer InstalledVersions root package install_path when available', function () { + if (! class_exists(\Composer\InstalledVersions::class)) { + expect(true)->toBeTrue(); + + return; + } + + Config::reset(); + + $root = Config::getProjectRoot(); + $expected = rtrim(str_replace('\\', '/', (string) realpath(__DIR__ . '/../../')), '/'); + + expect($root)->toBe($expected); + }); + + test('resolves project root from deep web subdirectory cwd (e.g. public/admin/dashboard)', function () { + $tempBase = sys_get_temp_dir() . '/typephp_deep_cwd_' . uniqid(); + mkdir($tempBase . '/public/admin/dashboard', 0777, true); + + file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/web-app'])); + + $startingSubdir = $tempBase . '/public/admin/dashboard'; + + try { + Config::reset(); + + $resolvedRoot = Config::getProjectRoot($startingSubdir); + $normalizedExpected = rtrim(str_replace('\\', '/', (string) realpath($tempBase)), '/'); + + expect($resolvedRoot)->toBe($normalizedExpected); + } finally { + @unlink($tempBase . '/composer.json'); + @rmdir($tempBase . '/public/admin/dashboard'); + @rmdir($tempBase . '/public/admin'); + @rmdir($tempBase . '/public'); + @rmdir($tempBase); + Config::reset(); + } + }); + + test('resolves project root when root has typephp.php without composer.json', function () { + $tempBase = sys_get_temp_dir() . '/typephp_typephp_config_' . uniqid(); + mkdir($tempBase . '/public', 0777, true); + + file_put_contents($tempBase . '/typephp.php', " true];"); + + $startingSubdir = $tempBase . '/public'; + + try { + Config::reset(); + + $resolvedRoot = Config::getProjectRoot($startingSubdir); + $normalizedExpected = rtrim(str_replace('\\', '/', (string) realpath($tempBase)), '/'); + + expect($resolvedRoot)->toBe($normalizedExpected); + } finally { + @unlink($tempBase . '/typephp.php'); + @rmdir($tempBase . '/public'); + @rmdir($tempBase); + Config::reset(); + } + }); + + test('handles deeply nested custom vendor-dir (e.g. packages/third-party/deps/typephp/typephp)', function () { + $tempBase = sys_get_temp_dir() . '/typephp_deep_vendor_' . uniqid(); + mkdir($tempBase . '/packages/third-party/deps/typephp/typephp/src/Internal/Util', 0777, true); + + file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/monorepo'])); + file_put_contents($tempBase . '/packages/third-party/deps/typephp/typephp/composer.json', json_encode(['name' => 'typephp/typephp'])); + + $simulatedLibDir = $tempBase . '/packages/third-party/deps/typephp/typephp/src/Internal/Util'; + + try { + Config::reset(); + + $resolvedRoot = Config::getProjectRoot($simulatedLibDir); + $normalizedExpected = rtrim(str_replace('\\', '/', (string) realpath($tempBase)), '/'); + + expect($resolvedRoot)->toBe($normalizedExpected); + } finally { + @unlink($tempBase . '/packages/third-party/deps/typephp/typephp/composer.json'); + @unlink($tempBase . '/composer.json'); + @rmdir($tempBase . '/packages/third-party/deps/typephp/typephp/src/Internal/Util'); + @rmdir($tempBase . '/packages/third-party/deps/typephp/typephp/src/Internal'); + @rmdir($tempBase . '/packages/third-party/deps/typephp/typephp/src'); + @rmdir($tempBase . '/packages/third-party/deps/typephp/typephp'); + @rmdir($tempBase . '/packages/third-party/deps/typephp'); + @rmdir($tempBase . '/packages/third-party/deps'); + @rmdir($tempBase . '/packages/third-party'); + @rmdir($tempBase . '/packages'); + @rmdir($tempBase); + Config::reset(); + } + }); + + test('handles Windows backslash startingDir paths cleanly', function () { + $tempBase = sys_get_temp_dir() . '/typephp_win_path_' . uniqid(); + mkdir($tempBase . '/vendor/typephp/typephp/src/Internal/Util', 0777, true); + + file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/win-app'])); + file_put_contents($tempBase . '/vendor/typephp/typephp/composer.json', json_encode(['name' => 'typephp/typephp'])); + + $windowsPath = str_replace('/', '\\', $tempBase . '/vendor/typephp/typephp/src/Internal/Util'); + + try { + Config::reset(); + + $resolvedRoot = Config::getProjectRoot($windowsPath); + $normalizedExpected = rtrim(str_replace('\\', '/', (string) realpath($tempBase)), '/'); + + expect($resolvedRoot)->toBe($normalizedExpected); + } finally { + @unlink($tempBase . '/vendor/typephp/typephp/composer.json'); + @unlink($tempBase . '/composer.json'); + @rmdir($tempBase . '/vendor/typephp/typephp/src/Internal/Util'); + @rmdir($tempBase . '/vendor/typephp/typephp/src/Internal'); + @rmdir($tempBase . '/vendor/typephp/typephp/src'); + @rmdir($tempBase . '/vendor/typephp/typephp'); + @rmdir($tempBase . '/vendor/typephp'); + @rmdir($tempBase . '/vendor'); + @rmdir($tempBase); + Config::reset(); + } + }); +}); From d8cfbeee6db4ec561e48a378c1e875fc240875fd Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 14:27:11 +0800 Subject: [PATCH 22/23] Add security audit job to CI workflow and update PHP version in setup --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++---------- composer.json | 3 ++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c7ecbf..3430a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,23 +12,43 @@ permissions: contents: read jobs: + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 + with: + php-version: '8.4' + coverage: none + + - name: Install Dependencies + uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 + + - name: Run Security Audit (Composer Audit) + run: composer audit + style: name: Check Code Style runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 with: php-version: '8.5' extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: none - name: Install Dependencies - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 - name: Check Code Style (Pint) run: ./vendor/bin/pint --test @@ -39,17 +59,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 with: php-version: '8.5' extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: none - name: Install Dependencies - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 - name: Run Static Analysis (PHPStan) run: ./vendor/bin/phpstan analyse --no-progress @@ -66,17 +86,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 with: php-version: ${{ matrix.php }} extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: ${{ matrix.os == 'ubuntu-latest' && matrix.php == '8.4' && 'pcov' || 'none' }} - name: Install Dependencies - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 with: dependency-versions: ${{ matrix.php == '8.2' && 'lowest' || 'highest' }} @@ -92,7 +112,7 @@ jobs: if: "! (matrix.os == 'ubuntu-latest' && matrix.php == '8.4')" - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@7f8b3261c2b5d74961760728368266191b90a2d3 # v5.3.1 if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/composer.json b/composer.json index 02a4dfe..42aed6a 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "nikic/php-parser": "^5.3" }, "require-dev": { + "roave/security-advisories": "dev-latest", "laravel/pint": "^1.10", "pestphp/pest": "^2.0 || ^3.0 || ^4.0", "phpstan/phpstan": "^2.1", @@ -90,4 +91,4 @@ "phpstan/extension-installer": true } } -} +} \ No newline at end of file From 26256ccac621ae18c749aa86c4fdb7ec83e1aef6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 10 Oct 2026 14:30:52 +0800 Subject: [PATCH 23/23] Update CI workflow to use stable versions for actions and dependencies --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3430a21..77b4d30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,16 +18,16 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 - name: Setup PHP - uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 + uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: none - name: Install Dependencies - uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 + uses: ramsey/composer-install@v3 - name: Run Security Audit (Composer Audit) run: composer audit @@ -38,17 +38,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 - name: Setup PHP - uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 + uses: shivammathur/setup-php@v2 with: php-version: '8.5' extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: none - name: Install Dependencies - uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 + uses: ramsey/composer-install@v3 - name: Check Code Style (Pint) run: ./vendor/bin/pint --test @@ -59,17 +59,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 - name: Setup PHP - uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 + uses: shivammathur/setup-php@v2 with: php-version: '8.5' extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: none - name: Install Dependencies - uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 + uses: ramsey/composer-install@v3 - name: Run Static Analysis (PHPStan) run: ./vendor/bin/phpstan analyse --no-progress @@ -86,17 +86,17 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 - name: Setup PHP - uses: shivammathur/setup-php@c541c155eee95eac327059db6fe2353fe1e1cf96 # v2.32.0 + uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo coverage: ${{ matrix.os == 'ubuntu-latest' && matrix.php == '8.4' && 'pcov' || 'none' }} - name: Install Dependencies - uses: ramsey/composer-install@57532f4beed50d268d86e1054e605f4e64364024 # v3.0.0 + uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.php == '8.2' && 'lowest' || 'highest' }} @@ -112,7 +112,7 @@ jobs: if: "! (matrix.os == 'ubuntu-latest' && matrix.php == '8.4')" - name: Upload Coverage to Codecov - uses: codecov/codecov-action@7f8b3261c2b5d74961760728368266191b90a2d3 # v5.3.1 + uses: codecov/codecov-action@v5 if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' with: token: ${{ secrets.CODECOV_TOKEN }}