From d23c71114265ef03ed9cf5ba97481ec6de87378e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 29 Aug 2026 00:25:32 +0800 Subject: [PATCH 1/3] Optimize hoth paths in runtime validation --- .gitignore | 1 + src/Contract/ContractParser.php | 2 + src/Internal/Checker/InlineChecker.php | 34 ++++- src/Internal/Checker/ParamChecker.php | 27 ++-- src/Internal/Checker/ReturnChecker.php | 20 ++- src/Resolver/TemplateManager.php | 175 ++++++++++++++---------- src/Validator/IdentifierValidator.php | 65 ++++++++- src/Validator/TypeValidatorRegistry.php | 20 ++- 8 files changed, 237 insertions(+), 107 deletions(-) diff --git a/.gitignore b/.gitignore index c814674..01e5913 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /manual-tests composer.lock index.php +benchmark.php .php-cs-fixer.cache \ No newline at end of file diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index befcb33..78da573 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -21,6 +21,7 @@ use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use TypePHP\Internal\Checker\InlineChecker; use TypePHP\Internal\Config; use TypePHP\Internal\StubManager; use TypePHP\Resolver\SpecialTypeResolver; @@ -68,6 +69,7 @@ public static function reset(): void self::$propertyCache = []; self::$magicMethodCache = []; self::$classLevelDocCache = []; + InlineChecker::reset(); DocblockExtractor::reset(); FileFilter::reset(); TypeValidatorRegistry::reset(); diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 05ce585..8237fd5 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -42,6 +42,22 @@ final class InlineChecker */ private static array $parsedTypeNodeCache = []; + /** + * In-memory cache for fully resolved type nodes per type string and file. + * + * @var array + */ + private static array $resolvedTypeNodeCache = []; + + /** + * Resets internal type node caches. Useful for test isolation. + */ + public static function reset(): void + { + self::$parsedTypeNodeCache = []; + self::$resolvedTypeNodeCache = []; + } + /** * Fast lookup set for scalar refinement types. */ @@ -103,15 +119,21 @@ public static function checkVariable(mixed $value, string $typeString, string $v } try { - $typeString = DocblockNormalizer::normalize($typeString); - $typeNode = self::parseTypeString($typeString); + $cacheKey = $typeString . '|' . $file; + if (isset(self::$resolvedTypeNodeCache[$cacheKey])) { + $typeNode = self::$resolvedTypeNodeCache[$cacheKey]; + } else { + $normalized = DocblockNormalizer::normalize($typeString); + $typeNode = self::parseTypeString($normalized); + + if ($file !== '') { + $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); + } - if ($file !== '') { - $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); + $typeNode = self::resolveCallerContext($typeNode); + self::$resolvedTypeNodeCache[$cacheKey] = $typeNode; } - $typeNode = self::resolveCallerContext($typeNode); - if (! self::shouldValidateType($typeNode, $config)) { return $value; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 83f3f06..f388713 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -49,14 +49,17 @@ public static function checkParams( string $function, array $vars, object|string|null $thisOrClass, - TypeValidatorRegistry $registry + TypeValidatorRegistry $registry, + string $effectiveFunction = '' ): ?ErrorMessage { if (! Config::isParamsEnabled()) { return null; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + if ($effectiveFunction === '') { + $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + } $magicError = self::handleMagicCall($effectiveFunction, $vars, $thisObj, $registry); if ($magicError !== null) { @@ -140,20 +143,23 @@ public static function resolveEffectiveFunction(string $function, object|string| } $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : ''); - $cacheKey = $function . '|' . $actualClassName; + if ($actualClassName === '') { + return $function; + } + + [$classOrTrait, $methodName] = explode('::', $function, 2); + $cacheKey = $function . '|' . $actualClassName; if (isset(self::$effectiveFunctionCache[$cacheKey])) { return self::$effectiveFunctionCache[$cacheKey]; } - [$classOrTrait, $methodName] = explode('::', $function, 2); - - $effectiveFunction = ($actualClassName !== '' && $actualClassName !== $classOrTrait) + $effectiveFunction = ($actualClassName !== $classOrTrait) ? $actualClassName . '::' . $methodName : $function; if ($thisObj !== null) { - $targetClass = $actualClassName !== '' ? $actualClassName : $classOrTrait; + $targetClass = $actualClassName; $traitAliases = HierarchyResolver::getTraitAliases($targetClass); if (\count($traitAliases) > 0) { @@ -195,8 +201,11 @@ private static function handleMagicCall( ?object $thisObj, TypeValidatorRegistry $registry ): ?ErrorMessage { - $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) { + if (! Config::isMagicMethodsEnabled()) { + return null; + } + + if (! str_ends_with($effectiveFunction, '::__call') && ! str_ends_with($effectiveFunction, '::__callStatic')) { return null; } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index abad760..42636bd 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -106,20 +106,23 @@ private static function resolveEffectiveFunction(string $function, object|string } $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : ''); - $cacheKey = $function . '|' . $actualClassName; + if ($actualClassName === '') { + return $function; + } + [$classOrTrait, $methodName] = explode('::', $function, 2); + + $cacheKey = $function . '|' . $actualClassName; if (isset(self::$effectiveFunctionCache[$cacheKey])) { return self::$effectiveFunctionCache[$cacheKey]; } - [$classOrTrait, $methodName] = explode('::', $function, 2); - - $effectiveFunction = ($actualClassName !== '' && $actualClassName !== $classOrTrait) + $effectiveFunction = ($actualClassName !== $classOrTrait) ? $actualClassName . '::' . $methodName : $function; if ($thisObj !== null) { - $targetClass = $actualClassName !== '' ? $actualClassName : $classOrTrait; + $targetClass = $actualClassName; $traitAliases = HierarchyResolver::getTraitAliases($targetClass); if (\count($traitAliases) > 0) { @@ -163,8 +166,11 @@ private static function handleMagicReturn( TypeValidatorRegistry $registry, callable $wrapIterableCallback ): mixed { - $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) { + if (! Config::isMagicMethodsEnabled()) { + return null; + } + + if (! str_ends_with($effectiveFunction, '::__call') && ! str_ends_with($effectiveFunction, '::__callStatic')) { return null; } diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 8d565c1..ad9480f 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -14,7 +14,6 @@ use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\ConstExprParser; -use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; @@ -48,6 +47,20 @@ final class TemplateManager */ private static array $callStackBindings = []; + /** + * Cache for resolved hierarchy templates and variances per class name. + * + * @var array, 1: array}> + */ + private static array $classHierarchyTemplatesCache = []; + + /** + * Cache for inherited template bindings per class name. + * + * @var array> + */ + private static array $classInheritedBindingsCache = []; + /** * Temporary storage for an original object instance being cloned. */ @@ -60,6 +73,8 @@ public static function reset(): void { self::$instanceTemplateBindings = null; self::$callStackBindings = []; + self::$classHierarchyTemplatesCache = []; + self::$classInheritedBindingsCache = []; self::$pendingCloneSource = null; } @@ -170,14 +185,12 @@ public static function getTemplateVariances(object $instance): array try { $stubDoc = StubManager::getClassDoc($className); + /** @var class-string $className */ $ref = new \ReflectionClass($className); $classDoc = $stubDoc ?? $ref->getDocComment(); if ($classDoc !== false && $classDoc !== null) { - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); - - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); + $classPhpDocNode = DocblockExtractor::parseDocString($classDoc); return DocblockExtractor::extractTemplateVariances($classPhpDocNode); } @@ -278,6 +291,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t self::resolveInheritedTemplates($instance, $className); try { + /** @var class-string $className */ $ref = new \ReflectionClass($className); [$templates, $classVariances] = self::collectHierarchyTemplatesAndVariances($ref); @@ -304,9 +318,12 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t */ private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ref): array { - $classHierarchy = HierarchyResolver::getClassHierarchy($ref); - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + $className = $ref->getName(); + if (isset(self::$classHierarchyTemplatesCache[$className])) { + return self::$classHierarchyTemplatesCache[$className]; + } + $classHierarchy = HierarchyResolver::getClassHierarchy($ref); $templates = []; $classVariances = []; @@ -319,9 +336,7 @@ private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ continue; } - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); - + $classPhpDocNode = DocblockExtractor::parseDocString($classDoc); $hierTemplates = DocblockExtractor::extractTemplates($classPhpDocNode); $hierVariances = DocblockExtractor::extractTemplateVariances($classPhpDocNode); @@ -337,7 +352,7 @@ private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ } } - return [$templates, $classVariances]; + return self::$classHierarchyTemplatesCache[$className] = [$templates, $classVariances]; } /** @@ -413,10 +428,51 @@ public static function resolveInheritedTemplates(object $instance, string $targe { $actualClassName = \get_class($instance); + if (isset(self::$classInheritedBindingsCache[$actualClassName])) { + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } + + /** @var array $cachedBindings */ + $cachedBindings = self::$classInheritedBindingsCache[$actualClassName]; + if (\count($cachedBindings) > 0) { + /** @var array $existing */ + $existing = self::$instanceTemplateBindings[$instance] ?? []; + self::$instanceTemplateBindings[$instance] = [...$cachedBindings, ...$existing]; + } + + return; + } + + $resolvedClassBindings = self::computeClassInheritedBindings($actualClassName); + self::$classInheritedBindingsCache[$actualClassName] = $resolvedClassBindings; + + if (\count($resolvedClassBindings) > 0) { + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } + /** @var array $existing */ + $existing = self::$instanceTemplateBindings[$instance] ?? []; + self::$instanceTemplateBindings[$instance] = [...$resolvedClassBindings, ...$existing]; + } + } + + /** + * @return array + */ + private static function computeClassInheritedBindings(string $actualClassName): array + { + /** @var array $bindings */ + $bindings = []; + + if (! class_exists($actualClassName) && ! interface_exists($actualClassName) && ! trait_exists($actualClassName)) { + return []; + } + try { + /** @var class-string $actualClassName */ $ref = new \ReflectionClass($actualClassName); $classHierarchy = HierarchyResolver::getClassHierarchy($ref); - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); foreach ($classHierarchy as $hierClass) { $fileName = $hierClass->getFileName(); @@ -430,8 +486,7 @@ public static function resolveInheritedTemplates(object $instance, string $targe $docsToInspect = self::collectDocsForClassHierarchyMember($hierClass); foreach ($docsToInspect as $rawDoc) { - $classTokens = new TokenIterator($lexer->tokenize($rawDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); + $classPhpDocNode = DocblockExtractor::parseDocString($rawDoc); $declaredTemplateNames = []; foreach ($classPhpDocNode->getTags() as $tag) { @@ -445,7 +500,7 @@ public static function resolveInheritedTemplates(object $instance, string $targe foreach ($inheritedTags as $inheritedTag) { $genericTypeNode = $inheritedTag->type; if ($genericTypeNode instanceof GenericTypeNode) { - self::bindInheritedGenericTag($genericTypeNode, $hierClass, $declaredTemplateNames, $instance, $actualClassName); + self::collectInheritedGenericTagBindings($genericTypeNode, $hierClass, $declaredTemplateNames, $actualClassName, $bindings); } } } @@ -453,40 +508,21 @@ public static function resolveInheritedTemplates(object $instance, string $targe } catch (\Throwable $e) { // Silently ignore reflection or parsing errors } - } - - /** - * @param \ReflectionClass $hierClass - * - * @return array - */ - private static function collectDocsForClassHierarchyMember(\ReflectionClass $hierClass): array - { - $docs = []; - - $stubDoc = StubManager::getClassDoc($hierClass->getName()); - $classDoc = $stubDoc ?? $hierClass->getDocComment(); - if ($classDoc !== false && $classDoc !== null) { - $docs[] = $classDoc; - } - foreach (SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName()) as $tDoc) { - $docs[] = $tDoc; - } - - return $docs; + return $bindings; } /** - * @param \ReflectionClass $hierClass * @param array $declaredTemplateNames + * @param array $bindings + * @param \ReflectionClass $hierClass */ - private static function bindInheritedGenericTag( + private static function collectInheritedGenericTagBindings( GenericTypeNode $genericTypeNode, \ReflectionClass $hierClass, array $declaredTemplateNames, - object $instance, - string $actualClassName + string $actualClassName, + array &$bindings ): void { $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); @@ -501,6 +537,7 @@ private static function bindInheritedGenericTag( try { $stubDoc = StubManager::getClassDoc($parentName); + /** @var class-string $parentName */ $parentRef = new \ReflectionClass($parentName); $parentDoc = $stubDoc ?? $parentRef->getDocComment(); @@ -508,18 +545,9 @@ private static function bindInheritedGenericTag( return; } - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); - $parentTokens = new TokenIterator($lexer->tokenize($parentDoc)); - $parentPhpDocNode = $phpDocParser->parse($parentTokens); - + $parentPhpDocNode = DocblockExtractor::parseDocString($parentDoc); $parentTemplateNames = array_keys(DocblockExtractor::extractTemplates($parentPhpDocNode)); - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } - - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - foreach ($parentTemplateNames as $idx => $templateName) { if (isset($genericTypeNode->genericTypes[$idx])) { $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $hierClass); @@ -540,13 +568,33 @@ private static function bindInheritedGenericTag( $bindings[$templateName] = $resolved; } } - - self::$instanceTemplateBindings[$instance] = $bindings; } catch (\Throwable $e) { // Silently ignore reflection errors } } + /** + * @param \ReflectionClass $hierClass + * + * @return array + */ + private static function collectDocsForClassHierarchyMember(\ReflectionClass $hierClass): array + { + $docs = []; + + $stubDoc = StubManager::getClassDoc($hierClass->getName()); + $classDoc = $stubDoc ?? $hierClass->getDocComment(); + if ($classDoc !== false && $classDoc !== null) { + $docs[] = $classDoc; + } + + foreach (SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName()) as $tDoc) { + $docs[] = $tDoc; + } + + return $docs; + } + /** * Recursively checks if an existing type node satisfies an expected type node under a given variance modifier. */ @@ -838,29 +886,6 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return $n; } - /** - * Returns shared static instances of PHPStan's PhpDocParser and Lexer. - * - * @return array{PhpDocParser, Lexer} - */ - private static function getPhpDocParserComponents(): array - { - /** @var PhpDocParser|null $phpDocParser */ - static $phpDocParser = null; - /** @var Lexer|null $lexer */ - static $lexer = null; - - if ($phpDocParser === null || $lexer === null) { - $config = new ParserConfig(usedAttributes: []); - $lexer = new Lexer($config); - $constExprParser = new ConstExprParser($config); - $typeParser = new TypeParser($config, $constExprParser); - $phpDocParser = new PhpDocParser($config, $typeParser, $constExprParser); - } - - return [$phpDocParser, $lexer]; - } - /** * Returns shared static instances of PHPStan's TypeParser and Lexer. * diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 385b370..41250d8 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -21,9 +21,8 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var IdentifierTypeNode $identifierNode */ $identifierNode = $node; $name = $identifierNode->name; - $lower = strtolower($name); - $ok = match ($lower) { + $ok = match ($name) { 'int', 'integer' => \is_int($value), 'string' => \is_string($value), 'float', 'double' => \is_float($value) || \is_int($value), @@ -76,7 +75,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'open-resource' => \is_resource($value), 'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)', - default => $this->validateClassOrIgnore($value, $name), + default => $this->validateCaseInsensitiveOrClass($value, $name), }; if (! $ok) { @@ -86,6 +85,66 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } + private function validateCaseInsensitiveOrClass(mixed $value, string $name): bool + { + $lower = strtolower($name); + + return match ($lower) { + 'int', 'integer' => \is_int($value), + 'string' => \is_string($value), + 'float', 'double' => \is_float($value) || \is_int($value), + 'bool', 'boolean' => \is_bool($value), + 'array' => \is_array($value), + 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), + 'object', 'self', 'static', 'parent', '$this' => \is_object($value), + 'callable', 'pure-callable' => \is_callable($value), + 'iterable' => is_iterable($value), + 'resource' => \is_resource($value), + 'null' => $value === null, + 'true' => $value === true, + 'false' => $value === false, + 'mixed' => true, + 'scalar' => \is_scalar($value), + 'void' => $value === null, + 'never', 'never-return', 'never-returns', 'no-return' => false, + 'positive-int' => \is_int($value) && $value > 0, + 'negative-int' => \is_int($value) && $value < 0, + 'non-positive-int' => \is_int($value) && $value <= 0, + 'non-negative-int' => \is_int($value) && $value >= 0, + 'non-zero-int' => \is_int($value) && $value !== 0, + 'unsigned-int' => \is_int($value) && $value >= 0, + 'positive-float' => (\is_float($value) || \is_int($value)) && $value > 0, + 'negative-float' => (\is_float($value) || \is_int($value)) && $value < 0, + 'non-positive-float' => (\is_float($value) || \is_int($value)) && $value <= 0, + 'non-negative-float' => (\is_float($value) || \is_int($value)) && $value >= 0, + 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value !== 0 && $value !== 0.0, + 'class-string' => \is_string($value) + && ClassNameValidator::isValid($value) + && (class_exists($value) || interface_exists($value) || trait_exists($value) || enum_exists($value)), + 'interface-string' => \is_string($value) && interface_exists($value), + 'trait-string' => \is_string($value) && trait_exists($value), + 'enum-string' => \is_string($value) && enum_exists($value), + 'callable-string' => \is_string($value) && \is_callable($value), + 'numeric-string' => \is_string($value) && is_numeric($value), + 'non-empty-string' => \is_string($value) && $value !== '', + 'lowercase-string' => \is_string($value) && strtolower($value) === $value, + 'non-empty-lowercase-string' => \is_string($value) && $value !== '' && strtolower($value) === $value, + 'uppercase-string' => \is_string($value) && strtoupper($value) === $value, + 'non-empty-uppercase-string' => \is_string($value) && $value !== '' && strtoupper($value) === $value, + 'array-key' => \is_int($value) || \is_string($value), + 'literal-string' => \is_string($value), + 'truthy-string', 'non-falsy-string' => \is_string($value) && (bool) $value === true, + 'non-empty-array' => \is_array($value) && \count($value) > 0, + 'non-empty-list' => \is_array($value) && \count($value) > 0 && array_is_list($value), + 'number', 'numeric' => \is_int($value) || \is_float($value) || (\is_string($value) && is_numeric($value)), + 'truthy' => (bool) $value === true, + 'falsy', 'falsey' => (bool) $value === false, + 'open-resource' => \is_resource($value), + 'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)', + default => $this->validateClassOrIgnore($value, $name), + }; + } + private function validateClassOrIgnore(mixed $value, string $name): bool { if (! ClassNameValidator::isValid($name)) { diff --git a/src/Validator/TypeValidatorRegistry.php b/src/Validator/TypeValidatorRegistry.php index be191a5..cdf2d3a 100644 --- a/src/Validator/TypeValidatorRegistry.php +++ b/src/Validator/TypeValidatorRegistry.php @@ -21,6 +21,8 @@ */ final class TypeValidatorRegistry { + private IdentifierValidator $identifierValidator; + /** * @var array */ @@ -43,8 +45,9 @@ public static function reset(): void public function __construct() { + $this->identifierValidator = new IdentifierValidator(); $this->validators = [ - IdentifierTypeNode::class => new IdentifierValidator(), + IdentifierTypeNode::class => $this->identifierValidator, GenericTypeNode::class => new GenericValidator(), UnionTypeNode::class => new UnionValidator(), IntersectionTypeNode::class => new IntersectionValidator(), @@ -59,7 +62,7 @@ public function __construct() /** * Validates a value against an AST TypeNode and returns an ErrorMessage on failure or null on success. */ - public function validate(mixed $value, TypeNode $node, string $context): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context = ''): ?ErrorMessage { $isObj = \is_object($value); $nodeKey = null; @@ -74,13 +77,16 @@ public function validate(mixed $value, TypeNode $node, string $context): ?ErrorM } } - $validator = $this->validators[\get_class($node)] ?? null; - if ($validator === null) { - return null; + if ($node instanceof IdentifierTypeNode) { + $err = $this->identifierValidator->validate($value, $node, $context, $this); + } else { + $validator = $this->validators[\get_class($node)] ?? null; + if ($validator === null) { + return null; + } + $err = $validator->validate($value, $node, $context, $this); } - $err = $validator->validate($value, $node, $context, $this); - if ($err === null && $isObj && $nodeKey !== null) { $cache = self::$validatedObjectCache[$value] ?? []; $cache[$nodeKey] = true; From 2105106b5216af4e2f22e13dab42a447d77e7aeb Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 29 Aug 2026 01:29:41 +0800 Subject: [PATCH 2/3] Improve test coverage --- src/Validator/IdentifierValidator.php | 66 +--------------------- tests/Resolver/TemplateManagerTest.php | 7 +++ tests/RuntimeChecker/ReturnCheckerTest.php | 22 ++++++++ 3 files changed, 32 insertions(+), 63 deletions(-) diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 41250d8..6f9ca9a 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -21,8 +21,9 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var IdentifierTypeNode $identifierNode */ $identifierNode = $node; $name = $identifierNode->name; + $lower = strtolower($name); - $ok = match ($name) { + $ok = match ($lower) { 'int', 'integer' => \is_int($value), 'string' => \is_string($value), 'float', 'double' => \is_float($value) || \is_int($value), @@ -74,8 +75,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'falsy', 'falsey' => (bool) $value === false, 'open-resource' => \is_resource($value), 'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)', - - default => $this->validateCaseInsensitiveOrClass($value, $name), + default => $this->validateClassOrIgnore($value, $name), }; if (! $ok) { @@ -85,66 +85,6 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - private function validateCaseInsensitiveOrClass(mixed $value, string $name): bool - { - $lower = strtolower($name); - - return match ($lower) { - 'int', 'integer' => \is_int($value), - 'string' => \is_string($value), - 'float', 'double' => \is_float($value) || \is_int($value), - 'bool', 'boolean' => \is_bool($value), - 'array' => \is_array($value), - 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), - 'object', 'self', 'static', 'parent', '$this' => \is_object($value), - 'callable', 'pure-callable' => \is_callable($value), - 'iterable' => is_iterable($value), - 'resource' => \is_resource($value), - 'null' => $value === null, - 'true' => $value === true, - 'false' => $value === false, - 'mixed' => true, - 'scalar' => \is_scalar($value), - 'void' => $value === null, - 'never', 'never-return', 'never-returns', 'no-return' => false, - 'positive-int' => \is_int($value) && $value > 0, - 'negative-int' => \is_int($value) && $value < 0, - 'non-positive-int' => \is_int($value) && $value <= 0, - 'non-negative-int' => \is_int($value) && $value >= 0, - 'non-zero-int' => \is_int($value) && $value !== 0, - 'unsigned-int' => \is_int($value) && $value >= 0, - 'positive-float' => (\is_float($value) || \is_int($value)) && $value > 0, - 'negative-float' => (\is_float($value) || \is_int($value)) && $value < 0, - 'non-positive-float' => (\is_float($value) || \is_int($value)) && $value <= 0, - 'non-negative-float' => (\is_float($value) || \is_int($value)) && $value >= 0, - 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value !== 0 && $value !== 0.0, - 'class-string' => \is_string($value) - && ClassNameValidator::isValid($value) - && (class_exists($value) || interface_exists($value) || trait_exists($value) || enum_exists($value)), - 'interface-string' => \is_string($value) && interface_exists($value), - 'trait-string' => \is_string($value) && trait_exists($value), - 'enum-string' => \is_string($value) && enum_exists($value), - 'callable-string' => \is_string($value) && \is_callable($value), - 'numeric-string' => \is_string($value) && is_numeric($value), - 'non-empty-string' => \is_string($value) && $value !== '', - 'lowercase-string' => \is_string($value) && strtolower($value) === $value, - 'non-empty-lowercase-string' => \is_string($value) && $value !== '' && strtolower($value) === $value, - 'uppercase-string' => \is_string($value) && strtoupper($value) === $value, - 'non-empty-uppercase-string' => \is_string($value) && $value !== '' && strtoupper($value) === $value, - 'array-key' => \is_int($value) || \is_string($value), - 'literal-string' => \is_string($value), - 'truthy-string', 'non-falsy-string' => \is_string($value) && (bool) $value === true, - 'non-empty-array' => \is_array($value) && \count($value) > 0, - 'non-empty-list' => \is_array($value) && \count($value) > 0 && array_is_list($value), - 'number', 'numeric' => \is_int($value) || \is_float($value) || (\is_string($value) && is_numeric($value)), - 'truthy' => (bool) $value === true, - 'falsy', 'falsey' => (bool) $value === false, - 'open-resource' => \is_resource($value), - 'closed-resource' => ! \is_resource($value) && get_debug_type($value) === 'resource (closed)', - default => $this->validateClassOrIgnore($value, $name), - }; - } - private function validateClassOrIgnore(mixed $value, string $name): bool { if (! ClassNameValidator::isValid($name)) { diff --git a/tests/Resolver/TemplateManagerTest.php b/tests/Resolver/TemplateManagerTest.php index dac76da..1b026cd 100644 --- a/tests/Resolver/TemplateManagerTest.php +++ b/tests/Resolver/TemplateManagerTest.php @@ -235,4 +235,11 @@ ; }); }); + + test('resolveInheritedTemplates returns safely when class does not exist in reflection', function () { + $anonObj = new stdClass(); + TemplateManager::resolveInheritedTemplates($anonObj, 'NonExistentClass12345'); + + expect(TemplateManager::getBoundTemplatesForInstance($anonObj))->toBeEmpty(); + }); }); diff --git a/tests/RuntimeChecker/ReturnCheckerTest.php b/tests/RuntimeChecker/ReturnCheckerTest.php index 2874597..541ec7d 100644 --- a/tests/RuntimeChecker/ReturnCheckerTest.php +++ b/tests/RuntimeChecker/ReturnCheckerTest.php @@ -63,6 +63,28 @@ Config::reset(); } }); + + test('returns value cleanly when conditional return references non-existent method or class', function () { + $registry = new TypeValidatorRegistry(); + $conditional = new PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode( + '$flag', + new PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode('true'), + new PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode('int'), + new PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode('string'), + false + ); + + $result = ReturnChecker::checkReturn( + 'NonExistentClass123::nonExistentMethod', + 'hello', + null, + ['otherParam' => true], + $registry, + fn () => null + ); + + expect($result)->toBe('hello'); + }); }); describe('$this Identity Constraints', function () { From 1a59b60a7bd06d946278f353c36f3b008b90bf94 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 29 Aug 2026 01:31:39 +0800 Subject: [PATCH 3/3] fix failing test on 8.2 ci --- tests/Resolver/TemplateManagerTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Resolver/TemplateManagerTest.php b/tests/Resolver/TemplateManagerTest.php index 1b026cd..3f876c1 100644 --- a/tests/Resolver/TemplateManagerTest.php +++ b/tests/Resolver/TemplateManagerTest.php @@ -77,6 +77,13 @@ TemplateManager::bindTemplate('testFunc', null, 'T', new IdentifierTypeNode('string')); expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeTrue(); }); + + test('resolveInheritedTemplates returns safely when class does not exist in reflection', function () { + $anonObj = new stdClass(); + TemplateManager::resolveInheritedTemplates($anonObj, 'NonExistentClass12345'); + + expect(TemplateManager::getBoundTemplatesForInstance($anonObj))->toBeEmpty(); + }); }); describe('Instance WeakMap Bindings', function () { @@ -235,11 +242,4 @@ ; }); }); - - test('resolveInheritedTemplates returns safely when class does not exist in reflection', function () { - $anonObj = new stdClass(); - TemplateManager::resolveInheritedTemplates($anonObj, 'NonExistentClass12345'); - - expect(TemplateManager::getBoundTemplatesForInstance($anonObj))->toBeEmpty(); - }); });