diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c7ecbf0..77b4d30d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,26 @@ permissions: contents: read jobs: + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Install Dependencies + uses: ramsey/composer-install@v3 + + - name: Run Security Audit (Composer Audit) + run: composer audit + style: name: Check Code Style runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index 02a4dfef..42aed6a8 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 diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 48718acb..d6e600b9 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; /** @@ -140,9 +141,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 +184,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) { @@ -216,10 +234,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); @@ -237,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; @@ -247,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) { @@ -265,19 +332,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']), @@ -291,7 +351,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/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 5c9b6ab7..46389534 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)]); } @@ -757,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; } }); @@ -764,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/ConditionalChecker.php b/src/Internal/Checker/ConditionalChecker.php index d6ad145f..e09f5b0b 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) { @@ -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/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index 2519d774..33e1b6c2 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/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 7cccb1f2..4794a4c9 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) { @@ -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]; @@ -800,6 +812,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 +842,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 +934,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 +983,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/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 1d83cf74..76226158 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) @@ -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/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 285a062d..b6eb0002 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) { @@ -342,7 +347,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 +359,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/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index 0bf87e05..cc780f64 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -563,23 +563,37 @@ 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); + } + } } /** - * 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 +608,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/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index 5f29646b..0ce089d5 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/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 9c777dd3..17175ab0 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)) { @@ -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(); @@ -1709,24 +1739,35 @@ 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; } 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 +1792,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 +1809,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 +1824,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 c0b74629..de35ad3e 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}" @@ -1098,10 +1134,18 @@ 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])) { - $resolved = self::resolveTypeNodeAst($normalizedGenericTypes[$idx], $hierClass); + $typeArg = $normalizedGenericTypes[$idx]; + if ($hierAliases !== []) { + $typeArg = DocblockParser::substituteAliases($typeArg, $hierAliases); + } + if ($bindings !== []) { + $typeArg = TemplateSubstitutor::substitute($typeArg, $bindings); + } + $resolved = self::resolveTypeNodeAst($typeArg, $hierClass); if ($resolved instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); @@ -1151,10 +1195,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; } @@ -1303,7 +1389,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; } } @@ -1343,7 +1429,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; } @@ -1550,7 +1643,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); } @@ -1564,21 +1660,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) { @@ -1597,27 +1704,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 be32184e..b4ebe7ac 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; @@ -167,19 +175,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); } @@ -226,28 +234,38 @@ 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 { - $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( - self::substituteNode($param->type, $boundTemplates, $declaredTemplates, $visited), + $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, $effectiveDeclared, $visited), $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional - ), - $node->parameters - ); + ); + } - $returnType = self::substituteNode($node->returnType, $boundTemplates, $declaredTemplates, $visited); + $returnType = self::substituteNode($node->returnType, $boundTemplates, $effectiveDeclared, $visited); return new CallableTypeNode( $node->identifier, @@ -309,10 +327,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/Io/CacheManager.php b/src/Internal/Io/CacheManager.php index a7820130..4fc0b5be 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 3735121d..a9c2712b 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 */ @@ -89,6 +89,27 @@ 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 = []; + + /** + * In-memory cache for resolved positive realpath results: [$path] => string + * + * @var array + */ + private static array $realpathCache = []; + + /** + * In-memory cache for verified cached files: [$path] => true + * + * @var array + */ + private static array $verifiedCachedFiles = []; + /** * Native PHP functions that read raw source code for viewing, highlighting, or tokenizing. * @@ -135,6 +156,9 @@ public static function reset(): void self::$statCache = []; self::$staticNegativeStatCache = []; self::$appFileDecisionCache = []; + self::$bypassedPathCache = []; + self::$realpathCache = []; + self::$verifiedCachedFiles = []; PathMatcher::reset(); } @@ -322,7 +346,32 @@ 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); + } + + // 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') + || 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; + return $this->openDirectHandle($path, $mode, $options); } @@ -337,10 +386,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( @@ -583,17 +642,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; } @@ -606,7 +667,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(); @@ -678,7 +741,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(); @@ -696,7 +761,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(); @@ -714,7 +781,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(); @@ -734,9 +803,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(); @@ -839,15 +912,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) diff --git a/src/Internal/Resolver/HierarchyResolver.php b/src/Internal/Resolver/HierarchyResolver.php index a2e6ee59..8f16766d 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/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 556c433b..4449ddf7 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,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, @@ -290,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; @@ -328,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, @@ -382,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; @@ -494,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; @@ -560,7 +608,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; @@ -578,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; @@ -595,12 +645,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); @@ -621,13 +671,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); } @@ -637,19 +688,44 @@ 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( - self::resolve($param->type, $context, $thisObj), + $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( + $paramType, $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional ); - }, $node->parameters); + } - $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 @@ -700,7 +776,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; @@ -728,12 +805,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); @@ -751,32 +828,58 @@ 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( - self::resolveForFile($param->type, $file), + $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( + $paramType, $param->isReference, $param->isVariadic, $param->parameterName, $param->isOptional ); - }, $node->parameters); + } - $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 @@ -1056,8 +1159,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 +1195,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 +1208,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 +1231,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; } /** @@ -1189,7 +1308,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/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index ddafe3e1..7f7043b9 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; } @@ -519,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/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index c6db5c13..4cac4abc 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/src/Internal/Util/FileFilter.php b/src/Internal/Util/FileFilter.php index d097eeb0..ee5a7e73 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 32840123..a9b0d6a3 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; } @@ -256,7 +260,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 +270,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/src/Internal/Validator/ArrayValidator.php b/src/Internal/Validator/ArrayValidator.php index 59d869e8..3352a41a 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 32ccd7b6..7810e1fa 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/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index 9dc16c9a..b0b32de4 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -22,13 +22,36 @@ 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) { - '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/src/Internal/Validator/NullableValidator.php b/src/Internal/Validator/NullableValidator.php index f654ee10..6c1f02af 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); } } diff --git a/src/Internal/Validator/TypeValidatorRegistry.php b/src/Internal/Validator/TypeValidatorRegistry.php index 46662a40..f3abfc2d 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; diff --git a/src/Internal/Validator/UnionValidator.php b/src/Internal/Validator/UnionValidator.php index f7c2f15f..24ad17ff 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; + } } } diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index dfef09bc..7ccba227 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -5,11 +5,19 @@ namespace TypePHP\Internal\Wrapper; 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; use ReflectionFunction; use TypeError; use TypePHP\Exception\TypeError as TypePHPTypeError; @@ -32,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']). @@ -98,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) { @@ -115,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; } @@ -131,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; } @@ -141,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); } /** @@ -151,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; @@ -168,8 +192,12 @@ private static function createDispatcherClosure( } if (! $hasAnyRef) { - return function (...$args) use ($callable, $typeNode, $registry, $prefix) { - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + return function (...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -177,13 +205,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); + return function (&...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -191,16 +223,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) { + return function (mixed &$a = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a); @@ -208,16 +244,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) { + return function (mixed &$a = null, mixed $b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a, $b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -225,16 +265,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) { + return function (mixed $a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [$a, &$b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -242,16 +286,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) { + return function (mixed &$a = null, mixed &$b = null) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { $args = [&$a, &$b]; - self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable($a, $b); @@ -259,14 +307,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); + return function (&...$args) use ($callable, $typeNode, $registry, $prefix, $function, $paramName) { + $effectiveTypeNode = $typeNode->templateTypes !== [] + ? self::resolveEffectiveCallableTypeNode($typeNode, $args, $prefix, $registry, $callable, $function, $paramName) + : $typeNode; + + self::validateCallbackArguments($effectiveTypeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -274,12 +326,303 @@ 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, + string $function = '', + string $paramName = 'callback' + ): 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; + } + + $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); + + 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, + $function + ); + } + + 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, + $function + ); + } + } + + 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); + $monomorphizedNode = new CallableTypeNode($typeNode->identifier, $resolvedParams, $resolvedReturn, $typeNode->templateTypes); + + if ($function !== '') { + self::$specializedCallableCache[$function][$paramName][$sig] = $monomorphizedNode; + } + + return $monomorphizedNode; + } + + /** + * @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, + string $function = '' + ): void { + if ($typeNode instanceof NullableTypeNode) { + if ($value !== null) { + self::inferLocalTemplateFromParam($typeNode->type, $value, $localTemplates, $boundLocal, $context, $registry, $callable, $function); + } + + return; + } + + if ($typeNode instanceof IdentifierTypeNode && isset($localTemplates[$typeNode->name])) { + $tName = $typeNode->name; + $tTag = $localTemplates[$tName]; + + if ($tTag->bound !== null) { + $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; + } + + $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 && $tTag->bound !== null) { + $boundToCheck = $function !== '' + ? SpecialTypeResolver::resolve($tTag->bound, $function) + : $tTag->bound; + + if ($registry->validate($value, $boundToCheck, '') === 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, $function); + } + + 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, $function); + } + } + } + + return; + } + + if ($typeNode instanceof 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, $function); + } + } + + 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, $function); + } + } + } + } + + 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/src/TypePHP.php b/src/TypePHP.php index d7919d6e..3a885d3f 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/Contract/ViewTemplateAndConstructorExclusionTest.php b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php new file mode 100644 index 00000000..d964c0c4 --- /dev/null +++ b/tests/Contract/ViewTemplateAndConstructorExclusionTest.php @@ -0,0 +1,260 @@ +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' +parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); + + expect($transformed)->not()->toContain('function __construct'); + }); + + test('does NOT inject __construct when class is abstract', function () { + $code = <<<'PHP' +parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); + + expect($transformed)->not()->toContain('function __construct'); + }); + + test('injects __construct with zero parameters (params: []) on standalone concrete classes', function () { + $code = <<<'PHP' +parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->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' +parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->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' +parse($source); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->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 = StreamWrapper::getParser()->parse($source); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = StreamWrapper::getPrinter()->prettyPrint($newStmts); + + expect($transformed)->toContain('$this->buffer[$this->cursor++]') + ->and($transformed)->not()->toContain('checkProperty($this->cursor + 1') + ; + }); + }); +}); diff --git a/tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php b/tests/TypeChecking/ArraysAndShapes/ImportedTypeAliasSelfResolutionTest.php new file mode 100644 index 00000000..862517e7 --- /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, + ]); + }); +}); diff --git a/tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableLocalGenericsTest.php new file mode 100644 index 00000000..7b376184 --- /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); + }); + }); +}); diff --git a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php index 3fad3eee..659b1f04 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'); + }); + }); }); diff --git a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php new file mode 100644 index 00000000..7641b140 --- /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/Generics/InheritedConstructorClassTemplateTest.php b/tests/TypeChecking/Generics/InheritedConstructorClassTemplateTest.php new file mode 100644 index 00000000..76197351 --- /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') + ; + }); + }); +}); diff --git a/tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php b/tests/TypeChecking/Generics/MultiLevelGenericForwardingTest.php new file mode 100644 index 00000000..5bd8c582 --- /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) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php b/tests/TypeChecking/Generics/SubclassAliasInInheritedTagTest.php new file mode 100644 index 00000000..00cc805b --- /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']); + }); +}); diff --git a/tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php b/tests/TypeChecking/Generics/TemplatedPropertyDefaultSubclassTest.php new file mode 100644 index 00000000..1f23cf76 --- /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') + ; + }); +}); diff --git a/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php b/tests/TypeChecking/Scalars/ScalarClassCollisionTest.php new file mode 100644 index 00000000..dfb3cfb8 --- /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) + ; + }); +}); diff --git a/tests/Unit/ProjectRootCustomVendorDirTest.php b/tests/Unit/ProjectRootCustomVendorDirTest.php new file mode 100644 index 00000000..2b3b0019 --- /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(); + } + }); +});