diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 384d577d..53fa7f9c 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -550,7 +550,13 @@ private static function parseFunction(\ReflectionFunction $ref): array $type = new ArrayTypeNode($type); } $substitutedType = self::substituteAliases($type, $aliases); - $types[$paramName] = SpecialTypeResolver::resolve($substitutedType, $ref); + $resolvedType = SpecialTypeResolver::resolve($substitutedType, $ref); + + if ($resolvedType instanceof IdentifierTypeNode && strtolower($resolvedType->name) === 'mixed') { + continue; + } + + $types[$paramName] = $resolvedType; } $returnTag = DocblockExtractor::getReturnTag($phpDocNode); @@ -696,7 +702,13 @@ private static function parseMethodHierarchyDocs( $type = new ArrayTypeNode($type); } $substitutedType = self::substituteAliases($type, $aliases); - $types[$targetParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef); + $resolvedType = SpecialTypeResolver::resolve($substitutedType, $hierRef); + + if ($resolvedType instanceof IdentifierTypeNode && strtolower($resolvedType->name) === 'mixed') { + continue; + } + + $types[$targetParamName] = $resolvedType; } } @@ -790,7 +802,11 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref ) { $propType = new ArrayTypeNode($propType); } - $types[$paramName] = self::substituteAliases($propType, []); + $substitutedProp = self::substituteAliases($propType, []); + if ($substitutedProp instanceof IdentifierTypeNode && strtolower($substitutedProp->name) === 'mixed') { + continue; + } + $types[$paramName] = $substitutedProp; } } } @@ -798,7 +814,7 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref } /** - * Recursively substitutes all type aliases inside a TypeNode AST. + * Recursively substitutes all type aliases inside a TypeNode AST and simplifies unions/intersections containing `mixed`. * * @param array $aliases */ @@ -864,17 +880,39 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( + $types = array_map( fn ($t) => self::substituteAliases($t, $aliases), $node->types - )); + ); + + foreach ($types as $t) { + if ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed') { + return new IdentifierTypeNode('mixed'); + } + } + + return new UnionTypeNode($types); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( + $types = array_map( fn ($t) => self::substituteAliases($t, $aliases), $node->types - )); + ); + + $filtered = array_values(array_filter($types, function ($t) { + return ! ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed'); + })); + + if (\count($filtered) === 0) { + return new IdentifierTypeNode('mixed'); + } + + if (\count($filtered) === 1) { + return $filtered[0]; + } + + return new IntersectionTypeNode($filtered); } if ($node instanceof ArrayShapeNode) { @@ -901,4 +939,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} +} \ No newline at end of file diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 29c97c04..1c513976 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -78,6 +78,7 @@ public function enterNode(Node $node): array|int|null $effectiveVarName = ($varName !== '') ? $varName : 'return'; $checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName); $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine()); + $node->setAttribute('typephp_var_wrapped', true); } } } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index ba9ca787..aab4c4dd 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -36,8 +36,8 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); - $hasParam = $isClassMethod || str_contains($docText, '@param') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param'); - $hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')); + $hasParam = self::hasParamContracts($docText, $isClassMethod); + $hasReturn = ! $isMagicLifecycle && self::hasReturnContracts($docText, $isClassMethod); if (! $hasParam && ! $hasReturn) { return; @@ -61,6 +61,67 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $node->stmts = [...$injectedStmts, ...$node->stmts]; } + private static function hasParamContracts(string $docText, bool $isClassMethod): bool + { + if ($isClassMethod) { + return true; + } + + if (! str_contains($docText, '@param') && ! str_contains($docText, '@phpstan-param') && ! str_contains($docText, '@psalm-param') && ! str_contains($docText, '@template')) { + return false; + } + + if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param')) { + return true; + } + + if ((int) preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches) > 0) { + foreach ($matches[1] as $typeStr) { + $unionParts = explode('|', $typeStr); + $hasMixed = false; + foreach ($unionParts as $part) { + if (strtolower(trim($part)) === 'mixed') { + $hasMixed = true; + break; + } + } + + if (! $hasMixed) { + return true; + } + } + + return false; + } + + return false; + } + + private static function hasReturnContracts(string $docText, bool $isClassMethod): bool + { + if ($isClassMethod) { + return true; + } + + if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')) { + return true; + } + + if (preg_match('/@return\s+([^\s$]+)/', $docText, $matches) === 1) { + $returnTypeStr = $matches[1]; + $unionParts = explode('|', $returnTypeStr); + foreach ($unionParts as $part) { + if (strtolower(trim($part)) === 'mixed') { + return false; // Collapses to mixed + } + } + + return true; + } + + return false; + } + private static function shouldSkipInjection(string $docText): bool { $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); @@ -556,6 +617,10 @@ public function enterNode(Node $n): int|array|null } if ($n instanceof Node\Stmt\Return_) { + if ($n->getAttribute('typephp_var_wrapped') === true) { + return null; + } + $exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null')); $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); @@ -588,4 +653,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php b/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php index 1a66975a..cfad65e0 100644 --- a/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php +++ b/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use TypePHP\Internal\StreamWrapper; + /** * Function with broad return type, but specific inline @var on return statement */ @@ -64,4 +66,22 @@ function testInlineVarOnReturnInClosure(): array ->toThrow(TypeError::class, 'positive-int') ; }); + + test('inline @var on return statement is not double wrapped with checkReturn in AST', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkVariable') + ->and($transformed)->not()->toContain('checkReturn(__METHOD__, ($__typephpVal = \TypePHP\Internal\RuntimeTypeChecker::checkVariable') + ; + }); }); diff --git a/tests/TypeChecking/Boundaries/ParamContractsTest.php b/tests/TypeChecking/Boundaries/ParamContractsTest.php index b20df601..d7b428cb 100644 --- a/tests/TypeChecking/Boundaries/ParamContractsTest.php +++ b/tests/TypeChecking/Boundaries/ParamContractsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use TypePHP\Contract\ContractParser; use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Services\VariadicPropertyService; @@ -59,6 +60,17 @@ function testProcessIntKeyGenerator(iterable $items): array return $out; } +/** + * Function with purely mixed parameters + * + * @param mixed $data + * @param mixed $meta + */ +function testPureMixedParamFunction(mixed $data, mixed $meta): bool +{ + return true; +} + describe('Function & Method Parameter Contracts', function () { test('inherits variadic constructor parameter contracts from property @var array docblocks without double-wrapping', function () { $service = new VariadicPropertyService(['tag1', 'tag2'], new Dog(), new Dog()); @@ -100,6 +112,16 @@ function testProcessIntKeyGenerator(iterable $items): array ->toThrow(TypeError::class, 'Argument $strings[3] must be of type string') ; }); + + test('filters out pure mixed parameters so hasParamContract is false', function () { + $contract = ContractParser::parse('testPureMixedParamFunction'); + + expect($contract['types'])->toBeEmpty() + ->and($contract['hasParamContract'])->toBeFalse() + ; + + expect(testPureMixedParamFunction('anything', 12345))->toBeTrue(); + }); }); describe('Lazy Wrapped Callable Parameter Contracts', function () { @@ -155,6 +177,7 @@ function testProcessIntKeyGenerator(iterable $items): array }; expect(fn () => testProcessIntKeyGenerator($badKeyGenerator())) - ->toThrow(TypeError::class, 'Iterator $items key'); + ->toThrow(TypeError::class, 'Iterator $items key') + ; }); }); diff --git a/typephp.php b/typephp.php index 0a5de162..a6518e01 100644 --- a/typephp.php +++ b/typephp.php @@ -42,7 +42,7 @@ */ 'respect_ignore_tags' => true, - /* + /* |-------------------------------------------------------------------------- | Enable Caching & Cache Directory |-------------------------------------------------------------------------- @@ -67,7 +67,7 @@ // \Acme\Domain\TypePHPExtension::class, ], - /* + /* |-------------------------------------------------------------------------- | Array Validation Strategy |-------------------------------------------------------------------------- @@ -106,6 +106,17 @@ 'objects' => true, ], + /* + |-------------------------------------------------------------------------- + | Stub Files (DocBlock Overrides for Third-Party & Vendor Packages) + |-------------------------------------------------------------------------- + | Path globs or specific file paths containing stub files (.stub, .stub.php, .php) + | that override inaccurate or missing DocBlocks in third-party vendor packages. + */ + 'stubs' => [ + // 'stubs/**', + ], + /* |-------------------------------------------------------------------------- | Included Paths & Whitelisting