From 4fae6ae77882ce1e9e6d892a925f12ebd9d051a8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 24 Aug 2026 18:28:32 +0800 Subject: [PATCH 1/3] Enhance ContractParser to filter out 'mixed' types and improve handling of parameter contracts; update FunctionContractInjector to prevent double wrapping of return checks; add tests for mixed parameters and return validation --- src/Contract/ContractParser.php | 22 +++++++++++++--- src/Internal/ContractVisitor.php | 1 + .../Visitor/FunctionContractInjector.php | 4 +++ .../Boundaries/InlineReturnValidationTest.php | 20 +++++++++++++++ .../Boundaries/ParamContractsTest.php | 25 ++++++++++++++++++- typephp.php | 15 +++++++++-- 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 384d577d..816161bc 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; } } } 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..f3aee56f 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -556,6 +556,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); 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 From 2645a15e5d8fad2e06ba534f936d71560a6c098d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 24 Aug 2026 19:34:00 +0800 Subject: [PATCH 2/3] Enhance ContractParser to simplify unions/intersections containing 'mixed' types; refactor FunctionContractInjector to improve parameter and return contract checks --- src/Contract/ContractParser.php | 34 ++++++++-- .../Visitor/FunctionContractInjector.php | 67 ++++++++++++++++++- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 816161bc..53fa7f9c 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -814,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 */ @@ -880,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) { @@ -917,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/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index f3aee56f..eb1a0b7c 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 (preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches)) { + 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)) { + $returnTypeStr = $matches[1]; + $unionParts = explode('|', $returnTypeStr); + foreach ($unionParts as $part) { + if (strtolower(trim($part)) === 'mixed') { + return false; + } + } + + return true; + } + + return false; + } + private static function shouldSkipInjection(string $docText): bool { $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); @@ -592,4 +653,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} +} \ No newline at end of file From 0e98af7f869d91a12b489587a0c278bfa60a195b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 24 Aug 2026 19:45:22 +0800 Subject: [PATCH 3/3] Fix PHPstan errors --- src/Internal/Visitor/FunctionContractInjector.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index eb1a0b7c..aab4c4dd 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -64,7 +64,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): private static function hasParamContracts(string $docText, bool $isClassMethod): bool { if ($isClassMethod) { - return true; + return true; } if (! str_contains($docText, '@param') && ! str_contains($docText, '@phpstan-param') && ! str_contains($docText, '@psalm-param') && ! str_contains($docText, '@template')) { @@ -75,7 +75,7 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): return true; } - if (preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches)) { + if ((int) preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches) > 0) { foreach ($matches[1] as $typeStr) { $unionParts = explode('|', $typeStr); $hasMixed = false; @@ -91,7 +91,7 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): } } - return false; + return false; } return false; @@ -107,12 +107,12 @@ private static function hasReturnContracts(string $docText, bool $isClassMethod) return true; } - if (preg_match('/@return\s+([^\s$]+)/', $docText, $matches)) { + 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; + return false; // Collapses to mixed } }