From 431dc4c1db6e049e7e7b53677e88dc4395388fc8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 15:58:25 +0800 Subject: [PATCH 01/10] Add tests for sealed and unsealed array shapes; improve type error handling --- .../ArraysAndShapes/ArrayAndListTypesTest.php | 137 ++++++++++++------ .../ParameterShiftInheritanceTest.php | 71 ++++----- 2 files changed, 126 insertions(+), 82 deletions(-) diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php index 40c95b8..61c76b7 100644 --- a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php @@ -116,6 +116,27 @@ function testKeylessImplicitTupleShape(array $tuple): bool return true; } +/** + * 1. Sealed Shape (Default) + * + * @param array{id: positive-int, username: non-empty-string} $sealedPayload + */ +function testSealedShape(array $sealedPayload): bool +{ + return true; +} + +/** + * 2. Unsealed Typed Shape + * Requires 'id' to be positive-int, but permits additional string-string pairs + * + * @param array{id: positive-int, ...} $unsealedPayload + */ +function testUnsealedTypedShape(array $unsealedPayload): bool +{ + return true; +} + /** * Helpers for Issue #20 Edge Cases * @@ -161,9 +182,8 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when array contains an invalid class object', function () { - expect(fn () => testDogArrayParam([new Dog(), new Car()])) - ->toThrow(TypeError::class) - ; + expect(fn() => testDogArrayParam([new Dog(), new Car()])) + ->toThrow(TypeError::class); }); }); @@ -174,16 +194,14 @@ function testReturnKeylessTuple(bool $valid): array test('throws TypeError when key type is invalid', function () { // Integer key 0 instead of string key - expect(fn () => testAssocScoreArrayParam([0 => 100])) - ->toThrow(TypeError::class, 'key') - ; + expect(fn() => testAssocScoreArrayParam([0 => 100])) + ->toThrow(TypeError::class, 'key'); }); test('throws TypeError when value type is invalid', function () { // Negative integer -10 instead of positive-int - expect(fn () => testAssocScoreArrayParam(['alice' => -10])) - ->toThrow(TypeError::class, "['alice']") - ; + expect(fn() => testAssocScoreArrayParam(['alice' => -10])) + ->toThrow(TypeError::class, "['alice']"); }); }); @@ -193,23 +211,20 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when list contains associative keys', function () { - expect(fn () => testTagListParam(['tag' => 'php'])) - ->toThrow(TypeError::class, 'must be a list') - ; + expect(fn() => testTagListParam(['tag' => 'php'])) + ->toThrow(TypeError::class, 'must be a list'); }); test('throws TypeError when list contains an empty string', function () { - expect(fn () => testTagListParam(['php', ''])) - ->toThrow(TypeError::class, 'non-empty-string') - ; + expect(fn() => testTagListParam(['php', ''])) + ->toThrow(TypeError::class, 'non-empty-string'); }); test('accepts valid non-empty list and rejects empty array', function () { expect(testNonEmptyNumberListParam([1, 2, 3]))->toBe(3); - expect(fn () => testNonEmptyNumberListParam([])) - ->toThrow(TypeError::class, 'non-empty list') - ; + expect(fn() => testNonEmptyNumberListParam([])) + ->toThrow(TypeError::class, 'non-empty list'); }); }); @@ -228,9 +243,8 @@ function testReturnKeylessTuple(bool $valid): array 'math' => [100, -50], // -50 is not positive-int ]; - expect(fn () => testNestedMatrixParam($invalidMatrix)) - ->toThrow(TypeError::class) - ; + expect(fn() => testNestedMatrixParam($invalidMatrix)) + ->toThrow(TypeError::class); }); test('throws TypeError when nested list is associative', function () { @@ -238,9 +252,8 @@ function testReturnKeylessTuple(bool $valid): array 'math' => ['score' => 100], // Not a list ]; - expect(fn () => testNestedMatrixParam($invalidMatrix)) - ->toThrow(TypeError::class) - ; + expect(fn() => testNestedMatrixParam($invalidMatrix)) + ->toThrow(TypeError::class); }); }); @@ -260,9 +273,8 @@ function testReturnKeylessTuple(bool $valid): array new Producer(new Car()), // Car is not an Animal ]; - expect(fn () => testGenericProducerListParam($producers)) - ->toThrow(TypeError::class) - ; + expect(fn() => testGenericProducerListParam($producers)) + ->toThrow(TypeError::class); }); }); @@ -273,14 +285,12 @@ function testReturnKeylessTuple(bool $valid): array test('throws TypeError on invalid tuple element', function () { // First item -5 is not positive-int - expect(fn () => testTupleShapeParam([-5, 'alice'])) - ->toThrow(TypeError::class, "['0']") - ; + expect(fn() => testTupleShapeParam([-5, 'alice'])) + ->toThrow(TypeError::class, "['0']"); // Second item '' is not non-empty-string - expect(fn () => testTupleShapeParam([10, ''])) - ->toThrow(TypeError::class, "['1']") - ; + expect(fn() => testTupleShapeParam([10, ''])) + ->toThrow(TypeError::class, "['1']"); }); }); @@ -301,9 +311,8 @@ function testReturnKeylessTuple(bool $valid): array 'invalid_extra' => 999, // int given, but string expected by unsealed type ]; - expect(fn () => testUnsealedShapeParam($payload)) - ->toThrow(TypeError::class) - ; + expect(fn() => testUnsealedShapeParam($payload)) + ->toThrow(TypeError::class); }); }); @@ -316,7 +325,7 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when any variadic argument violates the array shape', function () { - expect(fn () => testVariadicArrayShapeParam( + expect(fn() => testVariadicArrayShapeParam( ['id' => 1, 'name' => 'Alice'], ['id' => -2, 'name' => 'Bob'] // -2 is not positive-int ))->toThrow(TypeError::class); @@ -340,9 +349,8 @@ function testReturnKeylessTuple(bool $valid): array 'tags' => ['php', ''], // Empty string violates list ]; - expect(fn () => testComplexNestedShapeParam($payload)) - ->toThrow(TypeError::class) - ; + expect(fn() => testComplexNestedShapeParam($payload)) + ->toThrow(TypeError::class); }); }); @@ -353,12 +361,12 @@ function testReturnKeylessTuple(bool $valid): array ['status_ok', 'code' => 200, [1, 2, 3]] ))->toBeTrue(); - expect(fn () => testLocalTupleAliasParam( + expect(fn() => testLocalTupleAliasParam( [[10, -5], ['a', 'b']], ['status_ok', 'code' => 200, [1, 2, 3]] ))->toThrow(TypeError::class, "Argument \$payload['0'][1] must be of type positive-int"); - expect(fn () => testLocalTupleAliasParam( + expect(fn() => testLocalTupleAliasParam( [[10, 20], ['a', 'b']], ['status_ok', 'code' => -100, [1, 2, 3]] ))->toThrow(TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int"); @@ -367,16 +375,49 @@ function testReturnKeylessTuple(bool $valid): array test('resolves keyless tuple shapes imported via @phpstan-import-type', function () { expect(testImportedTupleAliasParam([[100, 200], 'valid_string']))->toBeTrue(); - expect(fn () => testImportedTupleAliasParam([[100, 200], ''])) - ->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string") - ; + expect(fn() => testImportedTupleAliasParam([[100, 200], ''])) + ->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string"); }); test('validates keyless tuple shapes returned from functions', function () { expect(testReturnKeylessTuple(true))->toBe([[10, 20], 'bundle']); - expect(fn () => testReturnKeylessTuple(false)) - ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int") - ; + expect(fn() => testReturnKeylessTuple(false)) + ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int"); + }); +}); + +describe('Sealed vs Unsealed Array Shapes', function () { + describe('Sealed Shapes (array{id: int})', function () { + test('accepts exact declared shape keys', function () { + expect(testSealedShape(['id' => 10, 'username' => 'Alice']))->toBeTrue(); + }); + + test('throws TypeError when sealed shape receives unexpected extra key', function () { + expect(fn() => testSealedShape(['id' => 10, 'username' => 'Alice', 'extra_key' => 'bar'])) + ->toThrow(TypeError::class, "contains unsealed unexpected key 'extra_key'"); + }); + }); + + describe('Unsealed Typed Shapes (array{id: int, ...})', function () { + test('accepts required keys plus additional string-string pairs', function () { + $payload = [ + 'id' => 42, + 'category' => 'admin_user', + 'department' => 'engineering', + ]; + + expect(testUnsealedTypedShape($payload))->toBeTrue(); + }); + + test('throws TypeError when unsealed extra value violates unsealed type contract', function () { + $payload = [ + 'id' => 42, + 'code' => 999, // 999 is int, but unsealed type requires string value! + ]; + + expect(fn() => testUnsealedTypedShape($payload)) + ->toThrow(TypeError::class, "['code'] must be of type string, int (999) given"); + }); }); }); diff --git a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php index 43d9b08..032e4c7 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php @@ -43,25 +43,22 @@ test('throws TypeError when renamed $userId parameter fails parent inherited positive-int contract', function () { $service = new ChildShiftedMethodService(); - expect(fn () => $service->updateUser(-5, 'Alice', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int') - ; + expect(fn() => $service->updateUser(-5, 'Alice', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int'); }); test('throws TypeError when renamed $userName parameter fails parent inherited non-empty-string contract', function () { $service = new ChildShiftedMethodService(); - expect(fn () => $service->updateUser(42, '', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string') - ; + expect(fn() => $service->updateUser(42, '', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string'); }); test('throws TypeError when renamed $userOptions parameter fails parent inherited shape contract', function () { $service = new ChildShiftedMethodService(); - expect(fn () => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) - ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool") - ; + expect(fn() => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) + ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool"); }); }); @@ -71,15 +68,13 @@ }); test('throws TypeError when renamed $itemBatch parameter fails parent contract on static method', function () { - expect(fn () => ChildShiftedMethodService::processBatch([10, -5], 'json')) - ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int') - ; + expect(fn() => ChildShiftedMethodService::processBatch([10, -5], 'json')) + ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int'); }); test('throws TypeError when renamed $outputFormat parameter fails parent contract on static method', function () { - expect(fn () => ChildShiftedMethodService::processBatch([10, 20], '')) - ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string') - ; + expect(fn() => ChildShiftedMethodService::processBatch([10, 20], '')) + ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string'); }); }); @@ -89,13 +84,11 @@ expect($service->execute(200, 'valid_token'))->toBeTrue(); - expect(fn () => $service->execute(-10, 'valid_token')) - ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int') - ; + expect(fn() => $service->execute(-10, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); - expect(fn () => $service->execute(200, '')) - ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string') - ; + expect(fn() => $service->execute(200, '')) + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); }); test('inherits and validates contracts from Abstract Classes with renamed parameters', function () { @@ -103,9 +96,8 @@ expect($service->processItems([10, 20, 30]))->toBeTrue(); - expect(fn () => $service->processItems([10, -5, 30])) - ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int') - ; + expect(fn() => $service->processItems([10, -5, 30])) + ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int'); }); test('inherits and validates contracts from Traits with renamed parameters', function () { @@ -113,13 +105,11 @@ expect($service->logEvent(1, 'info_message'))->toBeTrue(); - expect(fn () => $service->logEvent(-1, 'info_message')) - ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int') - ; + expect(fn() => $service->logEvent(-1, 'info_message')) + ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int'); - expect(fn () => $service->logEvent(1, '')) - ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string') - ; + expect(fn() => $service->logEvent(1, '')) + ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string'); }); test('inherits Interface contracts when method is fulfilled by Trait with renamed parameters', function () { @@ -127,12 +117,25 @@ expect($service->runAction(100, 'valid_token'))->toBeTrue(); - expect(fn () => $service->runAction(-5, 'valid_token')) - ->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int') - ; + expect(fn() => $service->runAction(-5, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int'); - expect(fn () => $service->runAction(100, '')) + expect(fn() => $service->runAction(100, '')) ->toThrow(TypeError::class, 'Argument $actionToken must be of type non-empty-string'); }); }); }); + +describe('PHP 8.0+ Named Arguments on Subtype Methods with Renamed Parameters', function () { + test('validates named arguments passed in swapped order on subtype method with renamed parameters from Interface', function () { + $service = new ChildShiftedOopService(); + + expect($service->execute(authToken: 'valid_token', statusCode: 200))->toBeTrue(); + + expect(fn() => $service->execute(authToken: 'valid_token', statusCode: -10)) + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); + + expect(fn() => $service->execute(authToken: '', statusCode: 200)) + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); + }); +}); From f7de56e507679b52097a0d8a32db44b7f314bf18 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 16:32:34 +0800 Subject: [PATCH 02/10] Refactor type handling in checkParams and checkReturn methods; add BaseEntityFactory and related entity factories for testing --- src/Internal/Checker/ParamChecker.php | 12 +- src/Internal/Checker/ReturnChecker.php | 12 +- src/Internal/RuntimeTypeChecker.php | 17 +-- .../Visitor/FunctionContractInjector.php | 6 +- src/Resolver/SpecialTypeResolver.php | 108 +++++++++++------- .../Fixtures/Services/AdminEntityFactory.php | 9 ++ tests/Fixtures/Services/BaseEntityFactory.php | 79 +++++++++++++ .../Services/GrandChildEntityFactory.php | 9 ++ tests/Fixtures/Services/UserEntityFactory.php | 9 ++ .../LateStaticBindingReturnTest.php | 68 +++++++++++ 10 files changed, 269 insertions(+), 60 deletions(-) create mode 100644 tests/Fixtures/Services/AdminEntityFactory.php create mode 100644 tests/Fixtures/Services/BaseEntityFactory.php create mode 100644 tests/Fixtures/Services/GrandChildEntityFactory.php create mode 100644 tests/Fixtures/Services/UserEntityFactory.php create mode 100644 tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index da212dd..586cc4b 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -27,17 +27,19 @@ final class ParamChecker /** * @param array $vars */ - public static function checkParams(string $function, array $vars, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage + public static function checkParams(string $function, array $vars, object|string|null $thisOrClass, TypeValidatorRegistry $registry): ?ErrorMessage { if (! (bool) (Config::get()['params'] ?? true)) { return null; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = $function; - if ($thisObj !== null && str_contains($function, '::')) { + + if (str_contains($function, '::')) { [$classOrTrait, $methodName] = explode('::', $function, 2); - $actualClassName = \get_class($thisObj); - if ($actualClassName !== $classOrTrait) { + $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); + if ($actualClassName !== null && $actualClassName !== $classOrTrait) { $effectiveFunction = $actualClassName . '::' . $methodName; } } @@ -340,4 +342,4 @@ private static function resolveTemplateParam(TypeNode $typeNode, mixed $val, str return null; } -} +} \ No newline at end of file diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 89b2d74..b1e96b4 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -25,17 +25,19 @@ final class ReturnChecker /** * @param array $vars */ - public static function checkReturn(string $function, mixed $value, ?object $thisObj, array $vars, TypeValidatorRegistry $registry, callable $wrapIterableCallback): mixed + public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass, array $vars, TypeValidatorRegistry $registry, callable $wrapIterableCallback): mixed { if (! (bool) (Config::get()['returns'] ?? true)) { return $value; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = $function; - if ($thisObj !== null && str_contains($function, '::')) { + + if (str_contains($function, '::')) { [$classOrTrait, $methodName] = explode('::', $function, 2); - $actualClassName = \get_class($thisObj); - if ($actualClassName !== $classOrTrait) { + $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); + if ($actualClassName !== null && $actualClassName !== $classOrTrait) { $effectiveFunction = $actualClassName . '::' . $methodName; } } @@ -191,4 +193,4 @@ private static function resolveConditionalReturnType( return $returnTypeNode; } -} +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 41cf654..55f5f64 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -71,15 +71,16 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string * * @param array $vars */ - public static function setupScope(string $function, array $vars, ?object $thisObj = null): ErrorMessage|ScopeCleaner|null + public static function setupScope(string $function, array $vars, object|string|null $thisOrClass = null): ErrorMessage|ScopeCleaner|null { if (! self::isEnabled()) { return null; } - $err = self::checkParams($function, $vars, $thisObj); + $err = self::checkParams($function, $vars, $thisOrClass); if ($err !== null) { + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; if ($thisObj === null) { TemplateManager::popCallFrame($function); } @@ -87,6 +88,8 @@ public static function setupScope(string $function, array $vars, ?object $thisOb return $err; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + return $thisObj === null ? new ScopeCleaner($function) : null; } @@ -95,13 +98,13 @@ public static function setupScope(string $function, array $vars, ?object $thisOb * * @param array $vars */ - public static function checkParams(string $function, array $vars, ?object $thisObj = null): ?ErrorMessage + public static function checkParams(string $function, array $vars, object|string|null $thisOrClass = null): ?ErrorMessage { if (! self::isEnabled()) { return null; } - return ParamChecker::checkParams($function, $vars, $thisObj, self::getRegistry()); + return ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); } /** @@ -109,13 +112,13 @@ public static function checkParams(string $function, array $vars, ?object $thisO * * @param array $vars */ - public static function checkReturn(string $function, mixed $value, ?object $thisObj = null, array $vars = []): mixed + public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, array $vars = []): mixed { if (! self::isEnabled()) { return $value; } - return ReturnChecker::checkReturn($function, $value, $thisObj, $vars, self::getRegistry(), [self::class, 'wrapIterable']); + return ReturnChecker::checkReturn($function, $value, $thisOrClass, $vars, self::getRegistry(), [self::class, 'wrapIterable']); } /** @@ -207,4 +210,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} +} \ No newline at end of file diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index b2aefe7..ff437e0 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -48,9 +48,11 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; $hasThis = $isClassMethod && ! $node->isStatic(); + + // Pass $this for instance methods, static::class for static methods, or null for global functions $thisArg = $hasThis ? new Node\Expr\Variable('this') - : new Node\Expr\ConstFetch(new Node\Name('null')); + : ($isClassMethod ? new Node\Expr\ClassConstFetch(new Node\Name('static'), 'class') : new Node\Expr\ConstFetch(new Node\Name('null'))); $injectedStmts = []; @@ -543,4 +545,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} +} \ No newline at end of file diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index f723786..8401191 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -82,7 +82,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof IdentifierTypeNode) { - return self::resolveIdentifier($node, $declaringClass, $ref); + return self::resolveIdentifier($node, $declaringClass, $ref, $context, $thisObj); } if ($node instanceof ConstTypeNode) { @@ -90,8 +90,8 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof GenericTypeNode) { - $genericType = self::resolve($node->type, $ref, $thisObj); - $innerTypes = array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->genericTypes); + $genericType = self::resolve($node->type, $context, $thisObj); + $innerTypes = array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -101,27 +101,27 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof OffsetAccessTypeNode) { - return self::resolveOffsetAccess($node, $ref, $thisObj); + return self::resolveOffsetAccess($node, $context, $thisObj); } if ($node instanceof ArrayShapeNode) { - return self::resolveArrayShape($node, $ref, $thisObj); + return self::resolveArrayShape($node, $context, $thisObj); } if ($node instanceof ObjectShapeNode) { - return self::resolveObjectShape($node, $ref, $thisObj); + return self::resolveObjectShape($node, $context, $thisObj); } if ($node instanceof CallableTypeNode) { - return self::resolveCallable($node, $ref, $thisObj); + return self::resolveCallable($node, $context, $thisObj); } if ($node instanceof ConditionalTypeNode) { return new ConditionalTypeNode( - self::resolve($node->subjectType, $ref, $thisObj), - self::resolve($node->targetType, $ref, $thisObj), - self::resolve($node->if, $ref, $thisObj), - self::resolve($node->else, $ref, $thisObj), + self::resolve($node->subjectType, $context, $thisObj), + self::resolve($node->targetType, $context, $thisObj), + self::resolve($node->if, $context, $thisObj), + self::resolve($node->else, $context, $thisObj), $node->negated ); } @@ -129,27 +129,27 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof ConditionalTypeForParameterNode) { return new ConditionalTypeForParameterNode( $node->parameterName, - self::resolve($node->targetType, $ref, $thisObj), - self::resolve($node->if, $ref, $thisObj), - self::resolve($node->else, $ref, $thisObj), + self::resolve($node->targetType, $context, $thisObj), + self::resolve($node->if, $context, $thisObj), + self::resolve($node->else, $context, $thisObj), $node->negated ); } if ($node instanceof NullableTypeNode) { - return new NullableTypeNode(self::resolve($node->type, $ref, $thisObj)); + return new NullableTypeNode(self::resolve($node->type, $context, $thisObj)); } if ($node instanceof ArrayTypeNode) { - return new ArrayTypeNode(self::resolve($node->type, $ref, $thisObj)); + return new ArrayTypeNode(self::resolve($node->type, $context, $thisObj)); } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); + return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); } return $node; @@ -282,11 +282,35 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio /** * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ - private static function resolveIdentifier(IdentifierTypeNode $node, ?string $declaringClass, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): IdentifierTypeNode - { + private static function resolveIdentifier( + IdentifierTypeNode $node, + ?string $declaringClass, + \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, + \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, + ?object $thisObj = null + ): IdentifierTypeNode { $lower = strtolower($node->name); - if ($lower === '$this' || $lower === 'static') { + if ($lower === '$this') { + if ($thisObj !== null) { + return new IdentifierTypeNode(\get_class($thisObj)); + } + + return $node; + } + + if ($lower === 'static') { + if ($thisObj !== null) { + return new IdentifierTypeNode(\get_class($thisObj)); + } + + if (\is_string($context) && str_contains($context, '::')) { + $callingClass = explode('::', $context, 2)[0]; + if (class_exists($callingClass) || interface_exists($callingClass) || trait_exists($callingClass) || enum_exists($callingClass)) { + return new IdentifierTypeNode($callingClass); + } + } + return $node; } @@ -331,12 +355,13 @@ private static function resolveConstType(ConstTypeNode $node, ?string $declaring } /** - * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ - private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): TypeNode + private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): TypeNode { - $baseType = self::resolve($node->type, $ref, $thisObj); - $offsetType = self::resolve($node->offset, $ref, $thisObj); + $ref = self::getReflectionContext($context); + $baseType = self::resolve($node->type, $context, $thisObj); + $offsetType = self::resolve($node->offset, $context, $thisObj); $offsetKey = self::extractOffsetKey($offsetType); @@ -362,11 +387,12 @@ private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \Reflect } /** - * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ - private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ArrayShapeNode + private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): ArrayShapeNode { - $items = array_map(function ($item) use ($ref, $thisObj) { + $ref = self::getReflectionContext($context); + $items = array_map(function ($item) use ($ref, $context, $thisObj) { /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ $keyName = $item->keyName; @@ -404,7 +430,7 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass return new ArrayShapeItemNode( $keyName, $item->optional, - self::resolve($item->valueType, $ref, $thisObj) + self::resolve($item->valueType, $context, $thisObj) ); }, $node->items); @@ -414,8 +440,8 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass $unsealedType = null; if ($node->unsealedType !== null) { - $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $ref, $thisObj) : null; - $unsealedValue = self::resolve($node->unsealedType->valueType, $ref, $thisObj); + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $context, $thisObj) : null; + $unsealedValue = self::resolve($node->unsealedType->valueType, $context, $thisObj); $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); } @@ -423,15 +449,15 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass } /** - * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ - private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ObjectShapeNode + private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): ObjectShapeNode { - $items = array_map(function ($item) use ($ref, $thisObj) { + $items = array_map(function ($item) use ($context, $thisObj) { return new ObjectShapeItemNode( $item->keyName, $item->optional, - self::resolve($item->valueType, $ref, $thisObj) + self::resolve($item->valueType, $context, $thisObj) ); }, $node->items); @@ -439,13 +465,13 @@ private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionCla } /** - * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ - private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): CallableTypeNode + private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): CallableTypeNode { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($ref, $thisObj) { + $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) { return new CallableTypeParameterNode( - self::resolve($param->type, $ref, $thisObj), + self::resolve($param->type, $context, $thisObj), $param->isReference, $param->isVariadic, $param->parameterName, @@ -453,7 +479,7 @@ private static function resolveCallable(CallableTypeNode $node, \ReflectionClass ); }, $node->parameters); - $resolvedReturnType = self::resolve($node->returnType, $ref, $thisObj); + $resolvedReturnType = self::resolve($node->returnType, $context, $thisObj); return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); } @@ -978,4 +1004,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi // Silently fall back to empty metadata if parsing fails } } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Services/AdminEntityFactory.php b/tests/Fixtures/Services/AdminEntityFactory.php new file mode 100644 index 0000000..40b7868 --- /dev/null +++ b/tests/Fixtures/Services/AdminEntityFactory.php @@ -0,0 +1,9 @@ + + */ + public static function createBatch(int $count): array + { + $batch = []; + for ($i = 0; $i < $count; $i++) { + $batch[] = new static(); + } + + return $batch; + } + + /** + * @return list + */ + public static function createBadBatch(): array + { + return [new static(), new stdClass()]; + } + + /** + * Instance method returning static + * + * @return static + */ + public function withSetting(string $key): static + { + return $this; + } + + /** + * Instance method returning wrong sibling instance + * + * @return static + */ + public function withBadSetting(): object + { + return new AdminEntityFactory(); + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/GrandChildEntityFactory.php b/tests/Fixtures/Services/GrandChildEntityFactory.php new file mode 100644 index 0000000..f793122 --- /dev/null +++ b/tests/Fixtures/Services/GrandChildEntityFactory.php @@ -0,0 +1,9 @@ +toBeInstanceOf(UserEntityFactory::class); + }); + + test('throws TypeError when parent static factory method returns stdClass instead of late-static-bound child class', function () { + expect(fn () => UserEntityFactory::createWrongInstance()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + }); + + test('throws TypeError when static factory method returns a sibling class instead of the called late-static class', function () { + expect(fn () => UserEntityFactory::createSibling()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + }); + }); + + describe('Multi-Level 3-Tier Late Static Binding (GrandParent -> Parent -> GrandChild)', function () { + test('resolves late-static-bound class to the deepest 3rd-tier descendant', function () { + $grandChild = GrandChildEntityFactory::create(); + + expect($grandChild)->toBeInstanceOf(GrandChildEntityFactory::class); + }); + + test('throws TypeError when 3rd-tier descendant returns an instance that violates the deepest child type', function () { + expect(fn () => GrandChildEntityFactory::createSibling()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\GrandChildEntityFactory'); + }); + }); + + describe('Collections of Late-Static-Bound Instances (list)', function () { + test('accepts list containing matching late-static-bound instances', function () { + $batch = UserEntityFactory::createBatch(3); + + expect($batch)->toHaveCount(3) + ->and($batch[0])->toBeInstanceOf(UserEntityFactory::class); + }); + + test('throws TypeError when list contains an item violating late-static-bound type', function () { + expect(fn () => UserEntityFactory::createBadBatch()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + }); + }); + + describe('Instance Methods Returning static ($this)', function () { + test('accepts valid $this instance returned from fluent instance method', function () { + $user = new UserEntityFactory(); + + expect($user->withSetting('theme'))->toBe($user); + }); + + test('throws TypeError when fluent instance method returns sibling instance', function () { + $user = new UserEntityFactory(); + + expect(fn () => $user->withBadSetting()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + }); + }); +}); \ No newline at end of file From b4f526ae9807d75173238ae1bcd9369ff3a0c3bc Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 16:49:31 +0800 Subject: [PATCH 03/10] Enhance type handling and testing across various components; introduce BaseGenericFactory and BaseGenericMap for improved generic handling; add comprehensive tests for nested type aliases and late static binding scenarios. --- src/Internal/Checker/ParamChecker.php | 18 +++- src/Internal/Checker/ReturnChecker.php | 2 +- src/Internal/RuntimeTypeChecker.php | 2 +- .../Visitor/FunctionContractInjector.php | 2 +- src/Resolver/SpecialTypeResolver.php | 4 +- .../Fixtures/Services/AdminEntityFactory.php | 2 +- .../Fixtures/Services/AdminGenericFactory.php | 14 +++ tests/Fixtures/Services/BaseEntityFactory.php | 2 +- .../Fixtures/Services/BaseGenericFactory.php | 69 ++++++++++++ tests/Fixtures/Services/BaseGenericMap.php | 73 +++++++++++++ .../Services/GrandChildEntityFactory.php | 2 +- tests/Fixtures/Services/UserEntityFactory.php | 2 +- .../Fixtures/Services/UserGenericFactory.php | 14 +++ tests/Fixtures/Services/UserGenericMap.php | 15 +++ tests/Fixtures/Types/NestedAliasService.php | 3 +- .../ArraysAndShapes/ArrayAndListTypesTest.php | 88 ++++++++------- .../ArraysAndShapes/NestedTypeAliasesTest.php | 29 +++-- .../LateStaticBindingReturnTest.php | 101 ++++++++++++++++-- .../ParameterShiftInheritanceTest.php | 67 +++++++----- 19 files changed, 415 insertions(+), 94 deletions(-) create mode 100644 tests/Fixtures/Services/AdminGenericFactory.php create mode 100644 tests/Fixtures/Services/BaseGenericFactory.php create mode 100644 tests/Fixtures/Services/BaseGenericMap.php create mode 100644 tests/Fixtures/Services/UserGenericFactory.php create mode 100644 tests/Fixtures/Services/UserGenericMap.php diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 586cc4b..2528cfd 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -17,6 +17,7 @@ use TypePHP\Internal\TypeFormatter; use TypePHP\Resolver\SpecialTypeResolver; use TypePHP\Resolver\TemplateManager; +use TypePHP\Resolver\TemplateSubstitutor; use TypePHP\Validator\TypeValidatorRegistry; /** @@ -78,6 +79,9 @@ public static function checkParams(string $function, array $vars, object|string| TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); } + $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates); + $declaredTemplates = $templates; + foreach ($contract['types'] as $paramName => $typeNode) { if (! \array_key_exists($paramName, $vars)) { continue; @@ -94,6 +98,18 @@ public static function checkParams(string $function, array $vars, object|string| $typeNode = $aliases[$typeNode->name]; } + $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); + + $isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name])) + || ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])); + + $shouldSkipTemplateSub = $isBareTemplate || $isClassStringT; + + if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); + } + if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { $err = self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates); if ($err !== null) { @@ -342,4 +358,4 @@ private static function resolveTemplateParam(TypeNode $typeNode, mixed $val, str return null; } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index b1e96b4..5a1bca6 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -193,4 +193,4 @@ private static function resolveConditionalReturnType( return $returnTypeNode; } -} \ No newline at end of file +} diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 55f5f64..f49c32a 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -210,4 +210,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} \ No newline at end of file +} diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index ff437e0..be5173f 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -545,4 +545,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} \ No newline at end of file +} diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 8401191..c85f804 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -281,6 +281,7 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio /** * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ private static function resolveIdentifier( IdentifierTypeNode $node, @@ -359,7 +360,6 @@ private static function resolveConstType(ConstTypeNode $node, ?string $declaring */ private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj): TypeNode { - $ref = self::getReflectionContext($context); $baseType = self::resolve($node->type, $context, $thisObj); $offsetType = self::resolve($node->offset, $context, $thisObj); @@ -1004,4 +1004,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi // Silently fall back to empty metadata if parsing fails } } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/AdminEntityFactory.php b/tests/Fixtures/Services/AdminEntityFactory.php index 40b7868..32c50b8 100644 --- a/tests/Fixtures/Services/AdminEntityFactory.php +++ b/tests/Fixtures/Services/AdminEntityFactory.php @@ -6,4 +6,4 @@ class AdminEntityFactory extends BaseEntityFactory { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/AdminGenericFactory.php b/tests/Fixtures/Services/AdminGenericFactory.php new file mode 100644 index 0000000..99efc26 --- /dev/null +++ b/tests/Fixtures/Services/AdminGenericFactory.php @@ -0,0 +1,14 @@ + + */ +class AdminGenericFactory extends BaseGenericFactory +{ +} diff --git a/tests/Fixtures/Services/BaseEntityFactory.php b/tests/Fixtures/Services/BaseEntityFactory.php index 7dfdf14..78ff656 100644 --- a/tests/Fixtures/Services/BaseEntityFactory.php +++ b/tests/Fixtures/Services/BaseEntityFactory.php @@ -76,4 +76,4 @@ public function withBadSetting(): object { return new AdminEntityFactory(); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/BaseGenericFactory.php b/tests/Fixtures/Services/BaseGenericFactory.php new file mode 100644 index 0000000..b6ad587 --- /dev/null +++ b/tests/Fixtures/Services/BaseGenericFactory.php @@ -0,0 +1,69 @@ + + * + * @template TValue + * + * @param TValue $value + * + * @return static + */ + public static function of(mixed $value): static + { + return new static($value); + } + + /** + * Static factory returning wrong item violating generic T + * + * @template TValue + * + * @param TValue $value + * + * @return static + */ + public static function ofBadItem(mixed $value): static + { + return new static(new stdClass()); + } + + /** + * Method returning Producer holding static instance: Producer> + * + * @return Producer> + */ + public function toProducer(): Producer + { + return new Producer($this); + } + + /** + * Method returning Producer holding sibling instance + * + * @return Producer> + */ + public function toBadProducer(): Producer + { + return new Producer(new AdminGenericFactory($this->item)); + } +} diff --git a/tests/Fixtures/Services/BaseGenericMap.php b/tests/Fixtures/Services/BaseGenericMap.php new file mode 100644 index 0000000..820d48f --- /dev/null +++ b/tests/Fixtures/Services/BaseGenericMap.php @@ -0,0 +1,73 @@ + $entries + */ + public function __construct(public array $entries = []) + { + } + + /** + * Static factory creating an instance with multiple bound templates + * + * @template TKey of array-key + * @template TVal + * + * @param TKey $key + * @param TVal $val + * + * @return static + */ + public static function fromEntry(mixed $key, mixed $val): static + { + return new static([$key => $val]); + } + + /** + * Factory returning Array Shape holding generic static instance + * + * @template TKey of array-key + * @template TVal + * + * @param TKey $key + * @param TVal $val + * + * @return array{instance: static, count: positive-int} + */ + public static function toShape(mixed $key, mixed $val): array + { + return [ + 'instance' => new static([$key => $val]), + 'count' => 1, + ]; + } + + /** + * Factory returning bad shape with invalid count + * + * @template TKey of array-key + * @template TVal + * + * @param TKey $key + * @param TVal $val + * + * @return array{instance: static, count: positive-int} + */ + public static function toBadShape(mixed $key, mixed $val): array + { + return [ + 'instance' => new static([$key => $val]), + 'count' => -1, // Violates positive-int in shape + ]; + } +} diff --git a/tests/Fixtures/Services/GrandChildEntityFactory.php b/tests/Fixtures/Services/GrandChildEntityFactory.php index f793122..a33d76f 100644 --- a/tests/Fixtures/Services/GrandChildEntityFactory.php +++ b/tests/Fixtures/Services/GrandChildEntityFactory.php @@ -6,4 +6,4 @@ class GrandChildEntityFactory extends UserEntityFactory { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/UserEntityFactory.php b/tests/Fixtures/Services/UserEntityFactory.php index 2e50d74..7a7e10b 100644 --- a/tests/Fixtures/Services/UserEntityFactory.php +++ b/tests/Fixtures/Services/UserEntityFactory.php @@ -6,4 +6,4 @@ class UserEntityFactory extends BaseEntityFactory { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/UserGenericFactory.php b/tests/Fixtures/Services/UserGenericFactory.php new file mode 100644 index 0000000..f1f26c2 --- /dev/null +++ b/tests/Fixtures/Services/UserGenericFactory.php @@ -0,0 +1,14 @@ + + */ +class UserGenericFactory extends BaseGenericFactory +{ +} diff --git a/tests/Fixtures/Services/UserGenericMap.php b/tests/Fixtures/Services/UserGenericMap.php new file mode 100644 index 0000000..ba425f4 --- /dev/null +++ b/tests/Fixtures/Services/UserGenericMap.php @@ -0,0 +1,15 @@ + + */ +class UserGenericMap extends BaseGenericMap +{ +} diff --git a/tests/Fixtures/Types/NestedAliasService.php b/tests/Fixtures/Types/NestedAliasService.php index 217e57a..d046438 100644 --- a/tests/Fixtures/Types/NestedAliasService.php +++ b/tests/Fixtures/Types/NestedAliasService.php @@ -13,7 +13,6 @@ * @phpstan-type LocalRecordShape array{id: LocalId, status: LocalStatus} * @phpstan-type LocalRecordList list * @phpstan-type ImportedRecordList list - * * @phpstan-type AdminStatus 'admin_active' * @phpstan-type UserStatus 'user_active' * @phpstan-type UnionOfAliases AdminStatus|UserStatus @@ -51,4 +50,4 @@ public function setUnionStatus(string $status): bool { return true; } -} \ No newline at end of file +} diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php index 61c76b7..5babcdb 100644 --- a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php @@ -182,8 +182,9 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when array contains an invalid class object', function () { - expect(fn() => testDogArrayParam([new Dog(), new Car()])) - ->toThrow(TypeError::class); + expect(fn () => testDogArrayParam([new Dog(), new Car()])) + ->toThrow(TypeError::class) + ; }); }); @@ -194,14 +195,16 @@ function testReturnKeylessTuple(bool $valid): array test('throws TypeError when key type is invalid', function () { // Integer key 0 instead of string key - expect(fn() => testAssocScoreArrayParam([0 => 100])) - ->toThrow(TypeError::class, 'key'); + expect(fn () => testAssocScoreArrayParam([0 => 100])) + ->toThrow(TypeError::class, 'key') + ; }); test('throws TypeError when value type is invalid', function () { // Negative integer -10 instead of positive-int - expect(fn() => testAssocScoreArrayParam(['alice' => -10])) - ->toThrow(TypeError::class, "['alice']"); + expect(fn () => testAssocScoreArrayParam(['alice' => -10])) + ->toThrow(TypeError::class, "['alice']") + ; }); }); @@ -211,20 +214,23 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when list contains associative keys', function () { - expect(fn() => testTagListParam(['tag' => 'php'])) - ->toThrow(TypeError::class, 'must be a list'); + expect(fn () => testTagListParam(['tag' => 'php'])) + ->toThrow(TypeError::class, 'must be a list') + ; }); test('throws TypeError when list contains an empty string', function () { - expect(fn() => testTagListParam(['php', ''])) - ->toThrow(TypeError::class, 'non-empty-string'); + expect(fn () => testTagListParam(['php', ''])) + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('accepts valid non-empty list and rejects empty array', function () { expect(testNonEmptyNumberListParam([1, 2, 3]))->toBe(3); - expect(fn() => testNonEmptyNumberListParam([])) - ->toThrow(TypeError::class, 'non-empty list'); + expect(fn () => testNonEmptyNumberListParam([])) + ->toThrow(TypeError::class, 'non-empty list') + ; }); }); @@ -243,8 +249,9 @@ function testReturnKeylessTuple(bool $valid): array 'math' => [100, -50], // -50 is not positive-int ]; - expect(fn() => testNestedMatrixParam($invalidMatrix)) - ->toThrow(TypeError::class); + expect(fn () => testNestedMatrixParam($invalidMatrix)) + ->toThrow(TypeError::class) + ; }); test('throws TypeError when nested list is associative', function () { @@ -252,8 +259,9 @@ function testReturnKeylessTuple(bool $valid): array 'math' => ['score' => 100], // Not a list ]; - expect(fn() => testNestedMatrixParam($invalidMatrix)) - ->toThrow(TypeError::class); + expect(fn () => testNestedMatrixParam($invalidMatrix)) + ->toThrow(TypeError::class) + ; }); }); @@ -273,8 +281,9 @@ function testReturnKeylessTuple(bool $valid): array new Producer(new Car()), // Car is not an Animal ]; - expect(fn() => testGenericProducerListParam($producers)) - ->toThrow(TypeError::class); + expect(fn () => testGenericProducerListParam($producers)) + ->toThrow(TypeError::class) + ; }); }); @@ -285,12 +294,14 @@ function testReturnKeylessTuple(bool $valid): array test('throws TypeError on invalid tuple element', function () { // First item -5 is not positive-int - expect(fn() => testTupleShapeParam([-5, 'alice'])) - ->toThrow(TypeError::class, "['0']"); + expect(fn () => testTupleShapeParam([-5, 'alice'])) + ->toThrow(TypeError::class, "['0']") + ; // Second item '' is not non-empty-string - expect(fn() => testTupleShapeParam([10, ''])) - ->toThrow(TypeError::class, "['1']"); + expect(fn () => testTupleShapeParam([10, ''])) + ->toThrow(TypeError::class, "['1']") + ; }); }); @@ -311,8 +322,9 @@ function testReturnKeylessTuple(bool $valid): array 'invalid_extra' => 999, // int given, but string expected by unsealed type ]; - expect(fn() => testUnsealedShapeParam($payload)) - ->toThrow(TypeError::class); + expect(fn () => testUnsealedShapeParam($payload)) + ->toThrow(TypeError::class) + ; }); }); @@ -325,7 +337,7 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when any variadic argument violates the array shape', function () { - expect(fn() => testVariadicArrayShapeParam( + expect(fn () => testVariadicArrayShapeParam( ['id' => 1, 'name' => 'Alice'], ['id' => -2, 'name' => 'Bob'] // -2 is not positive-int ))->toThrow(TypeError::class); @@ -349,8 +361,9 @@ function testReturnKeylessTuple(bool $valid): array 'tags' => ['php', ''], // Empty string violates list ]; - expect(fn() => testComplexNestedShapeParam($payload)) - ->toThrow(TypeError::class); + expect(fn () => testComplexNestedShapeParam($payload)) + ->toThrow(TypeError::class) + ; }); }); @@ -361,12 +374,12 @@ function testReturnKeylessTuple(bool $valid): array ['status_ok', 'code' => 200, [1, 2, 3]] ))->toBeTrue(); - expect(fn() => testLocalTupleAliasParam( + expect(fn () => testLocalTupleAliasParam( [[10, -5], ['a', 'b']], ['status_ok', 'code' => 200, [1, 2, 3]] ))->toThrow(TypeError::class, "Argument \$payload['0'][1] must be of type positive-int"); - expect(fn() => testLocalTupleAliasParam( + expect(fn () => testLocalTupleAliasParam( [[10, 20], ['a', 'b']], ['status_ok', 'code' => -100, [1, 2, 3]] ))->toThrow(TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int"); @@ -375,15 +388,17 @@ function testReturnKeylessTuple(bool $valid): array test('resolves keyless tuple shapes imported via @phpstan-import-type', function () { expect(testImportedTupleAliasParam([[100, 200], 'valid_string']))->toBeTrue(); - expect(fn() => testImportedTupleAliasParam([[100, 200], ''])) - ->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string"); + expect(fn () => testImportedTupleAliasParam([[100, 200], ''])) + ->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string") + ; }); test('validates keyless tuple shapes returned from functions', function () { expect(testReturnKeylessTuple(true))->toBe([[10, 20], 'bundle']); - expect(fn() => testReturnKeylessTuple(false)) - ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int"); + expect(fn () => testReturnKeylessTuple(false)) + ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int") + ; }); }); @@ -394,8 +409,9 @@ function testReturnKeylessTuple(bool $valid): array }); test('throws TypeError when sealed shape receives unexpected extra key', function () { - expect(fn() => testSealedShape(['id' => 10, 'username' => 'Alice', 'extra_key' => 'bar'])) - ->toThrow(TypeError::class, "contains unsealed unexpected key 'extra_key'"); + expect(fn () => testSealedShape(['id' => 10, 'username' => 'Alice', 'extra_key' => 'bar'])) + ->toThrow(TypeError::class, "contains unsealed unexpected key 'extra_key'") + ; }); }); @@ -416,7 +432,7 @@ function testReturnKeylessTuple(bool $valid): array 'code' => 999, // 999 is int, but unsealed type requires string value! ]; - expect(fn() => testUnsealedTypedShape($payload)) + expect(fn () => testUnsealedTypedShape($payload)) ->toThrow(TypeError::class, "['code'] must be of type string, int (999) given"); }); }); diff --git a/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php b/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php index 719a4cf..6f5968c 100644 --- a/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php @@ -22,22 +22,24 @@ $invalidRecords = [ ['id' => 10, 'status' => 'active'], - ['id' => -5, 'status' => 'pending'], + ['id' => -5, 'status' => 'pending'], ]; expect(fn () => $service->saveLocalRecords($invalidRecords)) - ->toThrow(TypeError::class, "['id'] must be of type positive-int"); + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; }); test('throws TypeError when nested shape item violates local union alias', function () { $service = new NestedAliasService(); $invalidRecords = [ - ['id' => 10, 'status' => 'archived'], + ['id' => 10, 'status' => 'archived'], ]; expect(fn () => $service->saveLocalRecords($invalidRecords)) - ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')"); + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") + ; }); }); @@ -57,11 +59,12 @@ $service = new NestedAliasService(); $invalidRecords = [ - ['id' => -100, 'status' => 'active'], + ['id' => -100, 'status' => 'active'], ]; expect(fn () => $service->saveImportedRecords($invalidRecords)) - ->toThrow(TypeError::class, "['id'] must be of type positive-int"); + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; }); test('throws TypeError when nested shape item violates imported union alias', function () { @@ -72,7 +75,8 @@ ]; expect(fn () => $service->saveImportedRecords($invalidRecords)) - ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')"); + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") + ; }); }); @@ -83,10 +87,12 @@ expect($service->saveChainedData(['code' => 50, 'label' => 'valid']))->toBeTrue(); expect(fn () => $service->saveChainedData(['code' => -10, 'label' => 'valid'])) - ->toThrow(TypeError::class, "['code'] must be of type positive-int"); + ->toThrow(TypeError::class, "['code'] must be of type positive-int") + ; expect(fn () => $service->saveChainedData(['code' => 50, 'label' => ''])) - ->toThrow(TypeError::class, "['label'] must be of type non-empty-string"); + ->toThrow(TypeError::class, "['label'] must be of type non-empty-string") + ; }); }); @@ -98,7 +104,8 @@ expect($service->setUnionStatus('user_active'))->toBeTrue(); expect(fn () => $service->setUnionStatus('guest_active')) - ->toThrow(TypeError::class, "('admin_active' | 'user_active')"); + ->toThrow(TypeError::class, "('admin_active' | 'user_active')") + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php b/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php index ea7b295..2e4b799 100644 --- a/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php +++ b/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php @@ -2,8 +2,14 @@ declare(strict_types=1); +use TypePHP\Tests\Fixtures\Domain\Cat; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\Producer; use TypePHP\Tests\Fixtures\Services\GrandChildEntityFactory; use TypePHP\Tests\Fixtures\Services\UserEntityFactory; +use TypePHP\Tests\Fixtures\Services\UserGenericFactory; +use TypePHP\Tests\Fixtures\Services\UserGenericMap; +use TypePHP\TypePHP; describe('Late Static Binding Return Contracts (@return static)', function () { describe('Static Factory Methods', function () { @@ -15,12 +21,14 @@ test('throws TypeError when parent static factory method returns stdClass instead of late-static-bound child class', function () { expect(fn () => UserEntityFactory::createWrongInstance()) - ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory') + ; }); test('throws TypeError when static factory method returns a sibling class instead of the called late-static class', function () { expect(fn () => UserEntityFactory::createSibling()) - ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory') + ; }); }); @@ -33,7 +41,8 @@ test('throws TypeError when 3rd-tier descendant returns an instance that violates the deepest child type', function () { expect(fn () => GrandChildEntityFactory::createSibling()) - ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\GrandChildEntityFactory'); + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\GrandChildEntityFactory') + ; }); }); @@ -42,12 +51,14 @@ $batch = UserEntityFactory::createBatch(3); expect($batch)->toHaveCount(3) - ->and($batch[0])->toBeInstanceOf(UserEntityFactory::class); + ->and($batch[0])->toBeInstanceOf(UserEntityFactory::class) + ; }); test('throws TypeError when list contains an item violating late-static-bound type', function () { expect(fn () => UserEntityFactory::createBadBatch()) - ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory') + ; }); }); @@ -55,14 +66,88 @@ test('accepts valid $this instance returned from fluent instance method', function () { $user = new UserEntityFactory(); - expect($user->withSetting('theme'))->toBe($user); + expect($user)->toBe($user->withSetting('theme')); }); test('throws TypeError when fluent instance method returns sibling instance', function () { $user = new UserEntityFactory(); expect(fn () => $user->withBadSetting()) - ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory') + ; }); }); -}); \ No newline at end of file + + describe('Generics Combined with Late Static Binding (static and Producer>)', function () { + test('creates generic static instance and binds template parameter dynamically', function () { + $dog = new Dog(); + $factory = UserGenericFactory::of($dog); + + expect($factory)->toBeInstanceOf(UserGenericFactory::class) + ->and($factory->item)->toBe($dog) + ->and(TypePHP::getGenericType($factory))->toBe(Dog::class) + ; + }); + + test('throws TypeError when generic static factory returns instance violating generic template T', function () { + expect(fn () => UserGenericFactory::ofBadItem(new Dog())) + ->toThrow(TypeError::class, 'UserGenericFactory, but TypePHP\Tests\Fixtures\Services\UserGenericFactory was returned') + ; + }); + + test('validates nested generic containers holding late-static instances (Producer>)', function () { + $dog = new Dog(); + $factory = new UserGenericFactory($dog); + $producer = $factory->toProducer(); + + expect($producer)->toBeInstanceOf(Producer::class) + ->and($producer->item)->toBe($factory) + ; + }); + + test('throws TypeError when nested generic container holds sibling class instead of late-static class', function () { + $dog = new Dog(); + $factory = new UserGenericFactory($dog); + + expect(fn () => $factory->toBadProducer()) + ->toThrow(TypeError::class, 'Producer>') + ; + }); + }); + + describe('Multi-Template Generics Combined with Late Static Binding (static)', function () { + test('creates multi-template generic static instance and binds K and V accurately', function () { + $dog = new Dog(); + $map = UserGenericMap::fromEntry('user_primary', $dog); + + expect($map)->toBeInstanceOf(UserGenericMap::class) + ->and(TypePHP::getGenericType($map, 'K'))->toBe('string') + ->and(TypePHP::getGenericType($map, 'V'))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($map))->toBe(['K' => 'string', 'V' => Dog::class]) + ; + }); + + test('validates array shapes containing generic late-static instances array{instance: static, count: int}', function () { + $dog = new Dog(); + $shape = UserGenericMap::toShape('user_1', $dog); + + expect($shape['instance'])->toBeInstanceOf(UserGenericMap::class) + ->and(TypePHP::getGenericType($shape['instance'], 'V'))->toBe(Dog::class) + ->and($shape['count'])->toBe(1) + ; + }); + + test('throws TypeError when array shape containing generic late-static instance violates inner shape contract', function () { + expect(fn () => UserGenericMap::toBadShape('user_1', new Dog())) + ->toThrow(TypeError::class, "Return value['count'] must be of type positive-int") + ; + }); + + test('throws TypeError on inline @var invariant generic mismatch when assigning static generic factory result', function () { + expect(function () { + /** @var UserGenericFactory $box */ + $box = UserGenericFactory::of(new Dog()); + })->toThrow(TypeError::class, 'UserGenericFactory, but TypePHP\Tests\Fixtures\Services\UserGenericFactory was given'); + }); + }); +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php index 032e4c7..6fa1c21 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php @@ -43,22 +43,25 @@ test('throws TypeError when renamed $userId parameter fails parent inherited positive-int contract', function () { $service = new ChildShiftedMethodService(); - expect(fn() => $service->updateUser(-5, 'Alice', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int'); + expect(fn () => $service->updateUser(-5, 'Alice', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int') + ; }); test('throws TypeError when renamed $userName parameter fails parent inherited non-empty-string contract', function () { $service = new ChildShiftedMethodService(); - expect(fn() => $service->updateUser(42, '', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string'); + expect(fn () => $service->updateUser(42, '', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string') + ; }); test('throws TypeError when renamed $userOptions parameter fails parent inherited shape contract', function () { $service = new ChildShiftedMethodService(); - expect(fn() => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) - ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool"); + expect(fn () => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) + ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool") + ; }); }); @@ -68,13 +71,15 @@ }); test('throws TypeError when renamed $itemBatch parameter fails parent contract on static method', function () { - expect(fn() => ChildShiftedMethodService::processBatch([10, -5], 'json')) - ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int'); + expect(fn () => ChildShiftedMethodService::processBatch([10, -5], 'json')) + ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int') + ; }); test('throws TypeError when renamed $outputFormat parameter fails parent contract on static method', function () { - expect(fn() => ChildShiftedMethodService::processBatch([10, 20], '')) - ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string'); + expect(fn () => ChildShiftedMethodService::processBatch([10, 20], '')) + ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string') + ; }); }); @@ -84,11 +89,13 @@ expect($service->execute(200, 'valid_token'))->toBeTrue(); - expect(fn() => $service->execute(-10, 'valid_token')) - ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); + expect(fn () => $service->execute(-10, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int') + ; - expect(fn() => $service->execute(200, '')) - ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); + expect(fn () => $service->execute(200, '')) + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string') + ; }); test('inherits and validates contracts from Abstract Classes with renamed parameters', function () { @@ -96,8 +103,9 @@ expect($service->processItems([10, 20, 30]))->toBeTrue(); - expect(fn() => $service->processItems([10, -5, 30])) - ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int'); + expect(fn () => $service->processItems([10, -5, 30])) + ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int') + ; }); test('inherits and validates contracts from Traits with renamed parameters', function () { @@ -105,11 +113,13 @@ expect($service->logEvent(1, 'info_message'))->toBeTrue(); - expect(fn() => $service->logEvent(-1, 'info_message')) - ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int'); + expect(fn () => $service->logEvent(-1, 'info_message')) + ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int') + ; - expect(fn() => $service->logEvent(1, '')) - ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string'); + expect(fn () => $service->logEvent(1, '')) + ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string') + ; }); test('inherits Interface contracts when method is fulfilled by Trait with renamed parameters', function () { @@ -117,11 +127,13 @@ expect($service->runAction(100, 'valid_token'))->toBeTrue(); - expect(fn() => $service->runAction(-5, 'valid_token')) - ->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int'); + expect(fn () => $service->runAction(-5, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int') + ; - expect(fn() => $service->runAction(100, '')) - ->toThrow(TypeError::class, 'Argument $actionToken must be of type non-empty-string'); + expect(fn () => $service->runAction(100, '')) + ->toThrow(TypeError::class, 'Argument $actionToken must be of type non-empty-string') + ; }); }); }); @@ -132,10 +144,11 @@ expect($service->execute(authToken: 'valid_token', statusCode: 200))->toBeTrue(); - expect(fn() => $service->execute(authToken: 'valid_token', statusCode: -10)) - ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); + expect(fn () => $service->execute(authToken: 'valid_token', statusCode: -10)) + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int') + ; - expect(fn() => $service->execute(authToken: '', statusCode: 200)) + expect(fn () => $service->execute(authToken: '', statusCode: 200)) ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); }); }); From 53ed687e1b13c0c77fc4b63109a59be0ac4fa51c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 16:53:05 +0800 Subject: [PATCH 04/10] Add Late Static Binding return contracts and improve documentation; remove variadic parameter contracts section --- docs/core-concepts/function-contracts.md | 118 ++++++++++++++++++----- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md index 543da06..2a704c7 100644 --- a/docs/core-concepts/function-contracts.md +++ b/docs/core-concepts/function-contracts.md @@ -36,6 +36,7 @@ registerUser(-5, 'Alice', 'admin'); > **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine *before* function execution begins. TypePHP's extended PHPDoc contracts (e.g., `positive-int`, `non-empty-string`) execute at the very start of the function/method body. If a native type hint fails, PHP throws its native `TypeError` before TypePHP's guard rails run. --- + ## PHP 8.0+ Named Arguments TypePHP natively supports PHP 8.0+ Named Arguments. Because parameter contracts are mapped by parameter name rather than argument position index, you can pass named arguments in any order, and TypePHP will accurately validate each parameter: @@ -62,6 +63,7 @@ registerUser(age: 25, username: 'Alice', id: 42); registerUser(age: 25, username: 'Alice', id: -5); // Throws: TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-5) given ``` + --- ## Class Methods (Instance & Static) @@ -189,29 +191,6 @@ getUserStatus(-10); --- -## Variadic Parameter Contracts - -When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list: - -```php -/** - * @param positive-int ...$ids - */ -function deleteUsers(int ...$ids): void -{ - // ... -} - -// Valid Call -deleteUsers(10, 20, 30); - -// Invalid Call (3rd variadic item violates positive-int) -deleteUsers(10, 20, -5); -// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int -``` - ---- - ## Fluent `$this` Identity Returns For fluent builder or service classes annotated with `@return $this`, TypePHP verifies strict object identity (`$result === $this`), preventing accidental instantiation of new instances: @@ -247,6 +226,96 @@ $builder->cloneSelf(); --- +## Late Static Binding Return Contracts (`@return static`) + +When a parent class method (static factory method or fluent instance method) is annotated with `@return static`, TypePHP enforces **Late Static Binding** at runtime. + +It dynamically verifies that the returned object is an instance of the **actual calling class** (`UserEntityFactory`), strictly rejecting parent instances (`BaseEntityFactory`), sibling instances (`AdminEntityFactory`), or generic objects (`stdClass`): + +```php +abstract class BaseEntityFactory +{ + /** + * @return static + */ + public static function create(): static + { + return new static(); + } + + /** + * @return static + */ + public static function createSibling(): object + { + return new AdminEntityFactory(); // Invalid: Returns sibling instead of calling class! + } +} + +class UserEntityFactory extends BaseEntityFactory {} +class AdminEntityFactory extends BaseEntityFactory {} + +// Valid: Returns UserEntityFactory instance matching the late-static calling class +$user = UserEntityFactory::create(); + +// Invalid: UserEntityFactory called, but AdminEntityFactory was returned! +UserEntityFactory::createSibling(); +// Throws: TypeError: UserEntityFactory::createSibling(): Return value must be of type App\UserEntityFactory, App\AdminEntityFactory returned +``` + +### Late Static Binding with Generics (`static`) + +Late static binding seamlessly integrates with TypePHP's Reified Generics engine. A static factory can return a specialized generic instance of the late-static-bound calling class: + +```php +/** + * @template T + */ +abstract class BaseGenericFactory +{ + /** + * @template TValue + * @param TValue $value + * @return static + */ + public static function of(mixed $value): static + { + return new static($value); + } +} + +class UserGenericFactory extends BaseGenericFactory {} + +// 1. Returns UserGenericFactory instance +// 2. Binds generic template T = Dog in WeakMap memory! +$factory = UserGenericFactory::of(new Dog()); +``` + +--- + +## Variadic Parameter Contracts + +When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list: + +```php +/** + * @param positive-int ...$ids + */ +function deleteUsers(int ...$ids): void +{ + // ... +} + +// Valid Call +deleteUsers(10, 20, 30); + +// Invalid Call (3rd variadic item violates positive-int) +deleteUsers(10, 20, -5); +// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int +``` + +--- + ## Conditional Return Types TypePHP supports parameter-based conditional return types (`@return ($param is true ? TypeA : TypeB)`): @@ -270,13 +339,14 @@ formatValue(false, 'hello'); // Evaluates return type as non-empty-string formatValue(true, 'not_an_int'); // Throws: TypeError: formatValue(): Return value must be of type positive-int ``` + --- ## PHP 8.0+ Attributes Coexistence TypePHP seamlessly coexists with native PHP 8.0+ Attributes (`#[Route]`, `#[Inject]`, `#[Validate]`). -You can place your PHPDoc annotations **either above or below** native PHP attributes on properties, methods/functions. TypePHP's AST engine and PHP's Reflection API process both metadata channels independently without any syntax conflicts: +You can place your PHPDoc annotations **either above or below** native PHP attributes on properties, methods, or functions. TypePHP's AST engine and PHP's Reflection API process both metadata channels independently without any syntax conflicts: ```php // Option A: DocBlock ABOVE Attribute (Supported) From 91089105c9f5a3161e94ab157aba36c4fae0347e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 17:26:59 +0800 Subject: [PATCH 05/10] Refactor CallableWrapper and IterableWrapper to use TypePHPTypeError for exception handling; add AdvancedEdgeCasesTest for comprehensive type validation scenarios --- src/Wrapper/CallableWrapper.php | 56 ++++++--- src/Wrapper/IterableWrapper.php | 7 +- .../Boundaries/AdvancedEdgeCasesTest.php | 110 ++++++++++++++++++ 3 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index a34f1f9..e869ec6 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -4,10 +4,13 @@ namespace TypePHP\Wrapper; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\TypeFormatter; use TypePHP\Validator\TypeValidatorRegistry; @@ -22,10 +25,6 @@ final class CallableWrapper */ public static function wrap(string $function, string $paramName, mixed $callable, TypeValidatorRegistry $registry): mixed { - if (! \is_callable($callable)) { - return $callable; - } - $contract = ContractParser::parse($function); $typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null); $aliases = $contract['aliases'] ?? []; @@ -36,16 +35,41 @@ public static function wrap(string $function, string $paramName, mixed $callable $prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName"; - return self::wrapTypeNode($typeNode, $callable, $prefix, $registry); + // 1. Single Callable + if (\is_callable($callable)) { + return self::wrapTypeNode($typeNode, $callable, $prefix, $registry); + } + + // 2. Collections of Callables (e.g. list or callable[]) + if (\is_array($callable) && $typeNode !== null) { + $innerCallableTypeNode = null; + + if ($typeNode instanceof GenericTypeNode && \in_array(strtolower($typeNode->type->name), ['list', 'array', 'iterable'], true)) { + $innerCallableTypeNode = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; + } elseif ($typeNode instanceof ArrayTypeNode) { + $innerCallableTypeNode = $typeNode->type; + } + + if ($innerCallableTypeNode instanceof CallableTypeNode) { + $wrappedArray = []; + foreach ($callable as $k => $item) { + if (\is_callable($item)) { + $itemPrefix = $prefix . (\is_int($k) ? "[$k]" : "['$k']"); + $wrappedArray[$k] = self::wrapTypeNode($innerCallableTypeNode, $item, $itemPrefix, $registry); + } else { + $wrappedArray[$k] = $item; + } + } + + return $wrappedArray; + } + } + + return $callable; } /** * Wraps a callable with runtime argument and return value type validation based on a CallableTypeNode AST. - * - * Performs the following steps: - * 1. Validates Closure type restrictions (Closure vs static-closure). - * 2. Returns an interceptor closure that validates arguments before invocation. - * 3. Validates return value after invocation and recursively wraps returned callbacks. */ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string $prefix, TypeValidatorRegistry $registry): mixed { @@ -63,7 +87,7 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); if ($err !== null) { - throw ErrorFactory::prepareException(new \TypeError($err->getMessage())); + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } if ($typeNode->returnType instanceof CallableTypeNode && \is_callable($result)) { @@ -80,13 +104,13 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string private static function enforceClosureConstraints(string $identifierName, mixed $callable, string $prefix): void { if (str_contains($identifierName, 'closure') && ! ($callable instanceof \Closure)) { - throw ErrorFactory::prepareException(new \TypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given')); + throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given')); } if (str_contains($identifierName, 'static') && $callable instanceof \Closure) { $refFunc = new \ReflectionFunction($callable); if ($refFunc->getClosureThis() !== null) { - throw ErrorFactory::prepareException(new \TypeError($prefix . ' must be a static Closure (not bound to $this)')); + throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be a static Closure (not bound to $this)')); } } } @@ -105,7 +129,7 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar for ($vIdx = $index; $vIdx < $argCount; $vIdx++) { $err = $registry->validate($args[$vIdx], $paramNode->type, "$prefix variadic argument #" . ($vIdx + 1)); if ($err !== null) { - throw ErrorFactory::prepareException(new \TypeError($err->getMessage())); + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } @@ -115,9 +139,9 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar if (\array_key_exists($index, $args)) { $err = $registry->validate($args[$index], $paramNode->type, "$prefix argument #" . ($index + 1)); if ($err !== null) { - throw ErrorFactory::prepareException(new \TypeError($err->getMessage())); + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } } } -} +} \ No newline at end of file diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 59cac68..65b53f9 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -9,6 +9,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\ErrorFactory; use TypePHP\Validator\TypeValidatorRegistry; @@ -108,14 +109,14 @@ private static function createValidationCallback( if ($keyTypeNode !== null && $key !== null) { $err = $registry->validate($key, $keyTypeNode, "$prefix key"); if ($err !== null) { - throw ErrorFactory::prepareException(new \TypeError($err->getMessage())); + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } if ($itemTypeNode !== null) { $err = $registry->validate($value, $itemTypeNode, "$prefix value"); if ($err !== null) { - throw ErrorFactory::prepareException(new \TypeError($err->getMessage())); + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } }; @@ -136,4 +137,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php new file mode 100644 index 0000000..40adee7 --- /dev/null +++ b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php @@ -0,0 +1,110 @@ + $formatters + */ +function processFormatterList(array $formatters, int $id): array +{ + $results = []; + foreach ($formatters as $formatter) { + $results[] = $formatter($id); + } + + return $results; +} + +describe('Advanced Edge-Case Behaviors', function () { + describe('Skipped and Deeply Nested Array Destructuring with @var', function () { + test('validates variables when skipping elements with empty commas in destructuring', function () { + /** + * @var positive-int $id + * @var non-empty-string $username + */ + [$id, , $username] = [10, 'skipped_token', 'Alice']; + + expect($id)->toBe(10) + ->and($username)->toBe('Alice'); + + expect(function () { + /** + * @var positive-int $id + * @var non-empty-string $username + */ + [$id, , $username] = [-5, 'skipped_token', 'Alice']; + })->toThrow(TypeError::class, 'Variable $id must be of type positive-int'); + }); + + test('validates variables in deeply nested array destructuring', function () { + /** + * @var positive-int $id + * @var non-empty-string $street + * @var int<10000, 99999> $zip + */ + [$id, [$street, $zip]] = [42, ['Broadway', 90210]]; + + expect($id)->toBe(42) + ->and($street)->toBe('Broadway') + ->and($zip)->toBe(90210); + + expect(function () { + /** + * @var positive-int $id + * @var non-empty-string $street + * @var int<10000, 99999> $zip + */ + [$id, [$street, $zip]] = [42, ['', 90210]]; + })->toThrow(TypeError::class, 'Variable $street must be of type non-empty-string'); + }); + }); + + describe('Nullable Generic Elements in Collections', function () { + test('accepts null and valid refined scalars in Collection', function () { + /** @var GenericCollection $collection */ + $collection = new GenericCollection(); + + $collection->add(10); + $collection->add(null); + $collection->add(20); + + expect($collection->count())->toBe(3) + ->and($collection->toArray())->toBe([10, null, 20]); + }); + + test('throws TypeError when adding invalid scalar to Collection', function () { + /** @var GenericCollection $collection */ + $collection = new GenericCollection(); + + expect(fn () => $collection->add(-50)) + ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given'); + }); + }); + + describe('Collections of Lazy Callables', function () { + test('executes and validates a list of lazy callable proxies', function () { + $formatters = [ + fn (int $id): string => "id_{$id}", + fn (int $id): string => "user#{$id}", + ]; + + $results = processFormatterList($formatters, 42); + expect($results)->toBe(['id_42', 'user#42']); + }); + + test('throws TypeError when a callable in the collection returns an invalid type', function () { + $formatters = [ + fn (int $id): string => "id_{$id}", + fn (int $id): string => '', + ]; + + expect(fn () => processFormatterList($formatters, 42)) + ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string'); + }); + }); +}); \ No newline at end of file From 0a0efdc2b474306d3e0f20d844703142e623b9f3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 18:05:45 +0800 Subject: [PATCH 06/10] Refactor CallableWrapper and CallableWrapperTest for improved type validation; enhance error messages for better clarity --- src/Wrapper/CallableWrapper.php | 29 +- src/Wrapper/IterableWrapper.php | 2 +- .../Services/InvokableFormatterService.php | 17 + .../Boundaries/AdvancedEdgeCasesTest.php | 21 +- .../CallableAndClosureContractsTest.php | 351 ++++++++---------- tests/Wrapper/CallableWrapperTest.php | 5 +- 6 files changed, 215 insertions(+), 210 deletions(-) create mode 100644 tests/Fixtures/Services/InvokableFormatterService.php diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index e869ec6..0c326ab 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -35,12 +35,10 @@ public static function wrap(string $function, string $paramName, mixed $callable $prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName"; - // 1. Single Callable if (\is_callable($callable)) { return self::wrapTypeNode($typeNode, $callable, $prefix, $registry); } - // 2. Collections of Callables (e.g. list or callable[]) if (\is_array($callable) && $typeNode !== null) { $innerCallableTypeNode = null; @@ -116,18 +114,21 @@ private static function enforceClosureConstraints(string $identifierName, mixed } /** - * Validates variadic and positional arguments passed into an intercepted callback. + * Validates variadic, positional, and named arguments passed into an intercepted callback. * * @param array $args */ private static function validateCallbackArguments(CallableTypeNode $typeNode, array $args, string $prefix, TypeValidatorRegistry $registry): void { - $argCount = \count($args); + $argValues = array_values($args); + $argCount = \count($argValues); foreach ($typeNode->parameters as $index => $paramNode) { + $rawParamName = ltrim($paramNode->parameterName ?? '', '$'); + if ($paramNode->isVariadic) { for ($vIdx = $index; $vIdx < $argCount; $vIdx++) { - $err = $registry->validate($args[$vIdx], $paramNode->type, "$prefix variadic argument #" . ($vIdx + 1)); + $err = $registry->validate($argValues[$vIdx], $paramNode->type, "$prefix variadic argument #" . ($vIdx + 1)); if ($err !== null) { throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } @@ -136,12 +137,24 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar break; } - if (\array_key_exists($index, $args)) { - $err = $registry->validate($args[$index], $paramNode->type, "$prefix argument #" . ($index + 1)); + $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)); + $err = $registry->validate($val, $paramNode->type, "$prefix $argLabel"); if ($err !== null) { throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } } } -} \ No newline at end of file +} diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 65b53f9..a5839bc 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -137,4 +137,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/InvokableFormatterService.php b/tests/Fixtures/Services/InvokableFormatterService.php new file mode 100644 index 0000000..5976d22 --- /dev/null +++ b/tests/Fixtures/Services/InvokableFormatterService.php @@ -0,0 +1,17 @@ +toBe(10) - ->and($username)->toBe('Alice'); + ->and($username)->toBe('Alice') + ; expect(function () { /** @@ -51,7 +52,8 @@ function processFormatterList(array $formatters, int $id): array expect($id)->toBe(42) ->and($street)->toBe('Broadway') - ->and($zip)->toBe(90210); + ->and($zip)->toBe(90210) + ; expect(function () { /** @@ -59,7 +61,7 @@ function processFormatterList(array $formatters, int $id): array * @var non-empty-string $street * @var int<10000, 99999> $zip */ - [$id, [$street, $zip]] = [42, ['', 90210]]; + [$id, [$street, $zip]] = [42, ['', 90210]]; })->toThrow(TypeError::class, 'Variable $street must be of type non-empty-string'); }); }); @@ -74,7 +76,8 @@ function processFormatterList(array $formatters, int $id): array $collection->add(20); expect($collection->count())->toBe(3) - ->and($collection->toArray())->toBe([10, null, 20]); + ->and($collection->toArray())->toBe([10, null, 20]) + ; }); test('throws TypeError when adding invalid scalar to Collection', function () { @@ -82,7 +85,8 @@ function processFormatterList(array $formatters, int $id): array $collection = new GenericCollection(); expect(fn () => $collection->add(-50)) - ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given'); + ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given') + ; }); }); @@ -100,11 +104,12 @@ function processFormatterList(array $formatters, int $id): array test('throws TypeError when a callable in the collection returns an invalid type', function () { $formatters = [ fn (int $id): string => "id_{$id}", - fn (int $id): string => '', + fn (int $id): string => '', ]; expect(fn () => processFormatterList($formatters, 42)) - ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php index 6d34ff5..fd549db 100644 --- a/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php +++ b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php @@ -4,9 +4,11 @@ use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; -use TypePHP\Tests\Fixtures\Generics\GenericCollection; use TypePHP\Tests\Fixtures\Generics\Producer; use TypePHP\Tests\Fixtures\Services\HelperService; +use TypePHP\Tests\Fixtures\Services\InvokableFormatterService; +use TypePHP\Tests\Fixtures\Types\CountableArrayAccess; +use TypePHP\Tests\Fixtures\Types\CountableOnly; /** * 1. Standard Callable Parameter Contract @@ -19,18 +21,27 @@ function testProcessUserCallback(callable $callback): bool } /** - * 2. Callback Receiving Bad Argument Inside Function + * 2. Named Parameter Callable Contract * - * @param callable(int): string $callback + * @param callable(positive-int $id, non-empty-string $username): string $callback */ -function testExecuteBadArgumentCallback(callable $callback): mixed +function testNamedParamCallback(callable $callback): string { - // Function passes a string to a callback expecting int - return $callback('not_an_int'); + return $callback(username: 'Alice', id: 42); } /** - * 3. Strict Closure Instance Parameter Contract + * 3. Named Parameter Callable Contract with Invalid Value + * + * @param callable(positive-int $id, non-empty-string $username): string $callback + */ +function testExecuteBadNamedParamCallback(callable $callback): string +{ + return $callback(username: '', id: 42); +} + +/** + * 4. Strict Closure Instance Parameter Contract * * @param Closure(positive-int): non-empty-string $closure */ @@ -40,7 +51,7 @@ function testProcessClosureOnly(Closure $closure): string } /** - * 4. Array Callable Parameter Contract + * 5. Array Callable Parameter Contract * * @param callable(positive-int): non-empty-string $callback */ @@ -50,53 +61,87 @@ function testProcessArrayCallable(callable $callback): string } /** - * 5. Helper for Array Callable Return Type Failure Test + * 6. Variadic Callback Parameters * - * @param callable(int): non-empty-string $callback + * @param callable(positive-int ...$ids): void $callback */ -function testExecuteTesterArrayCallable(callable $callback): string +function testVariadicCallbackParam(callable $callback): void { - return $callback(-5); + $callback(10, 20, 30); } /** - * 6. Variadic Callback Parameters (callable(positive-int ...$ids): void) + * 7. Optional Callback Parameters * - * @param callable(positive-int ...$ids): void $callback + * @param callable(positive-int, non-empty-string=): bool $callback */ -function testVariadicCallbackParam(callable $callback): void +function testOptionalCallbackParam(callable $callback, bool $passSecond = false): bool { - $callback(10, 20, 30); + if ($passSecond) { + return $callback(10, 'custom_name'); + } + + return $callback(10); } /** - * Helper for Invalid Variadic Callback Failure Test + * 8. Static Closure Contracts * - * @param callable(positive-int ...$ids): void $callback + * @param static-closure(int): string $closure */ -function testExecuteVariadicCallbackTester(callable $callback): void +function testStaticClosureParam(Closure $closure): string { - $callback(10, 20, -5); + return $closure(100); } /** - * 7. Optional Callback Parameters (callable(positive-int, non-empty-string=): bool) + * 9. Generic Container in Callable Contract (Valid) * - * @param callable(positive-int, non-empty-string=): bool $callback + * @param callable(Producer): Dog $processor */ -function testOptionalCallbackParam(callable $callback): bool +function testGenericCallableParam(callable $processor): Dog { - return $callback(10); // 2nd optional argument omitted + return $processor(new Producer(new Dog())); } /** - * 8. Static Closure Contracts (static-closure(int): string) + * 10. Generic Container in Callable Contract (Invalid Subtype) * - * @param static-closure(int): string $closure + * @param callable(Producer): Dog $processor */ -function testStaticClosureParam(Closure $closure): string +function testExecuteBadGenericCallableParam(callable $processor): Dog { - return $closure(100); + return $processor(new Producer(new Car())); // Passes Producer where Producer is required! +} + +/** + * 11. Typed Arrays and Shapes in Callable Contract + * + * @param callable(list, string[]): array{count: positive-int} $processor + */ +function testTypedArrayCallableParam(callable $processor): array +{ + return $processor([10, 20], ['tag1', 'tag2']); +} + +/** + * 12. Union Types in Callable Contract + * + * @param callable(positive-int|'active'): ('success'|'error') $processor + */ +function testUnionCallableParam(callable $processor, mixed $input): string +{ + return $processor($input); +} + +/** + * 13. Intersection Types in Callable Contract + * + * @param callable(Countable&ArrayAccess): bool $processor + */ +function testIntersectionCallableParam(callable $processor, object $collection): bool +{ + return $processor($collection); } describe('Standard Callable Contracts (callable(T1, T2): R)', function () { @@ -110,229 +155,153 @@ function testStaticClosureParam(Closure $closure): string $badReturnCallback = fn (int $id, string $name): int => 123; expect(fn () => testProcessUserCallback($badReturnCallback)) - ->toThrow(TypeError::class, 'Callback $callback return value') - ; - }); - - test('throws TypeError when function passes invalid argument into wrapped callback', function () { - $callback = fn (int $id): string => "id_{$id}"; - - expect(fn () => testExecuteBadArgumentCallback($callback)) - ->toThrow(TypeError::class, 'Callback $callback argument #1') + ->toThrow(TypeError::class, 'return value must be of type bool') ; }); }); -describe('Strict Closure Instance Contracts (Closure(T): R)', function () { - test('accepts native Closure instances', function () { - $closure = fn (int $id): string => "user_{$id}"; +describe('PHP 8.0+ Named Arguments on Callables', function () { + test('validates named arguments passed in swapped order to a wrapped callable', function () { + $callback = fn (int $id, string $username): string => "{$id}_{$username}"; - expect(testProcessClosureOnly($closure))->toBe('user_42'); + expect(testNamedParamCallback($callback))->toBe('42_Alice'); }); - test('throws TypeError when non-Closure callable (string function name) is passed', function () { - // 'strlen' is a valid callable, but NOT an instance of Closure - expect(fn () => testProcessClosureOnly('strlen')) - ->toThrow(TypeError::class, 'must be of type Closure') - ; - }); + test('throws TypeError when named argument passed to callable violates type contract', function () { + $callback = fn ($id, $username) => 'ok'; - test('throws TypeError when non-Closure callable (array callable) is passed', function () { - $service = new HelperService(); - - expect(fn () => testProcessClosureOnly([$service, 'formatUser'])) - ->toThrow(TypeError::class, 'must be of type Closure') + expect(fn () => testExecuteBadNamedParamCallback($callback)) + ->toThrow(TypeError::class, 'must be of type non-empty-string') ; }); }); -describe('Array & First-Class Callables ([$obj, "method"] & $obj->method(...))', function () { - test('accepts valid instance method array callable', function () { - $service = new HelperService(); +describe('Generics in Callables (callable(Producer): Dog)', function () { + test('accepts callback taking generic Producer and returning Dog', function () { + $cb = fn (Producer $p): Dog => $p->item; - expect(testProcessArrayCallable([$service, 'formatUser']))->toBe('user_100'); + expect(testGenericCallableParam($cb))->toBeInstanceOf(Dog::class); }); - test('accepts valid static method array callable', function () { - expect(testProcessArrayCallable([HelperService::class, 'staticFormat']))->toBe('static_user_100'); + test('throws TypeError when callback receives Producer with wrong generic subtype', function () { + $cb = fn (Producer $p): Dog => $p->item; + + expect(fn () => testExecuteBadGenericCallableParam($cb)) + ->toThrow(TypeError::class, 'Producer') + ; }); +}); - test('accepts valid PHP 8.1+ first-class callable syntax', function () { - $service = new HelperService(); +describe('Typed Arrays and Lists in Callables', function () { + test('accepts callback taking list and string[], returning array shape', function () { + $cb = fn (array $ids, array $tags): array => ['count' => \count($ids)]; - expect(testProcessArrayCallable($service->formatUser(...)))->toBe('user_100'); + expect(testTypedArrayCallableParam($cb))->toBe(['count' => 2]); }); - test('throws TypeError when method invoked via array callable returns invalid type', function () { - $service = new HelperService(); + test('throws TypeError when callback returns invalid array shape value', function () { + $badCb = fn (array $ids, array $tags): array => ['count' => -5]; // -5 violates positive-int! - expect(fn () => testExecuteTesterArrayCallable([$service, 'formatUser'])) - ->toThrow(TypeError::class, 'Callback $callback return value') + expect(fn () => testTypedArrayCallableParam($badCb)) + ->toThrow(TypeError::class, "['count'] must be of type positive-int") ; }); }); -describe('Advanced PHPStan Callable Specs (Variadics, Optional Args, Static Closures)', function () { - test('validates variadic callback arguments', function () { - $validVariadic = fn (int ...$ids) => null; - testVariadicCallbackParam($validVariadic); +describe('Unions in Callables', function () { + test('accepts callback handling union arguments and union return types', function () { + $cb = fn (int|string $val): string => 'success'; - // Third variadic argument -5 violates positive-int - expect(fn () => testExecuteVariadicCallbackTester($validVariadic)) - ->toThrow(TypeError::class, 'Callback $callback variadic argument #3') - ; + expect(testUnionCallableParam($cb, 100))->toBe('success'); + expect(testUnionCallableParam($cb, 'active'))->toBe('success'); }); - test('supports optional callback parameters (int=)', function () { - $callback = fn (int $id, ?string $name = null): bool => $id > 0; + test('throws TypeError when callback receives argument outside union contract', function () { + $cb = fn (int|string $val): string => 'success'; - expect(testOptionalCallbackParam($callback))->toBeTrue(); + expect(fn () => testUnionCallableParam($cb, -50)) + ->toThrow(TypeError::class, "must be of type (positive-int | 'active')") + ; }); - test('accepts static closures and rejects non-static closures for static-closure', function () { - $staticClosure = static fn (int $id): string => "static_{$id}"; - expect(testStaticClosureParam($staticClosure))->toBe('static_100'); + test('throws TypeError when callback returns value outside return union contract', function () { + $badCb = fn (int|string $val): string => 'invalid_return'; - $nonStaticClosure = fn (int $id): string => "bound_{$id}"; - expect(fn () => testStaticClosureParam($nonStaticClosure)) - ->toThrow(TypeError::class, 'must be a static Closure') + expect(fn () => testUnionCallableParam($badCb, 100)) + ->toThrow(TypeError::class, "must be of type ('success' | 'error')") ; }); }); -describe('Inline @var Callable Variable Contracts', function () { - test('enforces contracts on callables assigned to variables with @var annotation', function () { - /** @var callable(positive-int, non-empty-string): bool $formatter */ - $formatter = fn (int $id, string $name) => \strlen($name) > 0; +describe('Intersections in Callables (Countable & ArrayAccess)', function () { + test('accepts callback taking object satisfying intersection contract', function () { + $cb = fn (object $c): bool => \count($c) >= 0; + $collection = new CountableArrayAccess(); - expect($formatter(10, 'alice'))->toBeTrue(); - - expect(fn () => $formatter(-5, 'alice')) - ->toThrow(TypeError::class, 'Variable $formatter: Callback argument #1') - ; + expect(testIntersectionCallableParam($cb, $collection))->toBeTrue(); }); - test('enforces contracts on inline callable with array shapes and list parameters', function () { - /** @var callable(list, array{status: 'active'}): bool $processor */ - $processor = fn (array $ids, array $options) => \count($ids) > 0 && $options['status'] === 'active'; + test('throws TypeError when callback receives object failing intersection contract', function () { + $cb = fn (object $c): bool => true; + $onlyCountable = new CountableOnly(); - expect($processor([10, 20], ['status' => 'active']))->toBeTrue(); - - expect(fn () => $processor([10, -5], ['status' => 'active'])) - ->toThrow(TypeError::class, 'Variable $processor: Callback argument #1') - ; - - expect(fn () => $processor([10, 20], ['status' => 'inactive'])) - ->toThrow(TypeError::class, 'Variable $processor: Callback argument #2') + expect(fn () => testIntersectionCallableParam($cb, $onlyCountable)) + ->toThrow(TypeError::class, 'must be of type ArrayAccess') ; }); +}); - test('enforces contracts on inline callable return values with array shapes', function () { - /** @var callable(positive-int): array{id: positive-int, name: non-empty-string} $factory */ - $factory = function (int $id): array { - if ($id === 999) { - return ['id' => -1, 'name' => 'Alice']; // Invalid return shape (id is -1) - } - - return ['id' => $id, 'name' => 'Alice']; - }; - - expect($factory(10))->toBe(['id' => 10, 'name' => 'Alice']); +describe('Invokable Objects (__invoke) vs Closure Instances', function () { + test('accepts invokable class instance for callable(T): R', function () { + $invokable = new InvokableFormatterService(); - // Invalid return value - expect(fn () => $factory(999)) - ->toThrow(TypeError::class, 'Variable $factory: Callback return value') - ; + expect(testProcessArrayCallable($invokable))->toBe('invoked_100'); }); - test('enforces contracts on inline callable with generic object parameters', function () { - /** @var callable(Producer): Dog $extractor */ - $extractor = fn (Producer $producer) => $producer->item; - - expect($extractor(new Producer(new Dog())))->toBeInstanceOf(Dog::class); + test('throws TypeError when invokable class instance is passed where Closure is strictly required', function () { + $invokable = new InvokableFormatterService(); - // Invalid argument: Producer holding Car instead of Dog - expect(fn () => $extractor(new Producer(new Car()))) - ->toThrow(TypeError::class, 'Variable $extractor: Callback argument #1') + expect(fn () => testProcessClosureOnly($invokable)) + ->toThrow(TypeError::class, 'must be of type Closure') ; }); +}); - test('enforces contracts on inline callable with union parameters and nullable return', function () { - /** @var callable(positive-int|non-empty-string): ?positive-int $finder */ - $finder = function (int|string $query): ?int { - if ($query === 'not_found') { - return null; - } - if ($query === 'invalid') { - return -5; - } - - return \is_int($query) ? $query : \strlen($query); - }; - - expect($finder(10))->toBe(10); - expect($finder('hello'))->toBe(5); - expect($finder('not_found'))->toBeNull(); - - expect(fn () => $finder(0)) - ->toThrow(TypeError::class, 'Variable $finder: Callback argument #1') - ; +describe('Optional Callback Parameters (callable(T1, T2=): R)', function () { + test('accepts invocation when optional second argument is omitted', function () { + $callback = fn (int $id, ?string $name = null): bool => $id > 0; - expect(fn () => $finder('invalid')) - ->toThrow(TypeError::class, 'Variable $finder: Callback return value') - ; + expect(testOptionalCallbackParam($callback, false))->toBeTrue(); }); - test('enforces contracts on inline callable with deeply nested generic arguments', function () { - /** @var callable(GenericCollection>): positive-int $countDogs */ - $countDogs = fn (GenericCollection $collection) => $collection->count(); - - // Set up a valid collection: GenericCollection> - /** @var GenericCollection> $validCollection */ - $validCollection = new GenericCollection(); - $validCollection->add(new Producer(new Dog())); - - expect($countDogs($validCollection))->toBe(1); - - // Set up an invalid collection: GenericCollection> - /** @var GenericCollection> $invalidCollection */ - $invalidCollection = new GenericCollection(); - $invalidCollection->add(new Producer(new Car())); + test('accepts invocation when optional second argument is provided with valid value', function () { + $callback = fn (int $id, ?string $name = null): bool => $id > 0; - // Should fail because Producer is not Producer - expect(fn () => $countDogs($invalidCollection)) - ->toThrow(TypeError::class, 'Variable $countDogs: Callback argument #1') - ; + expect(testOptionalCallbackParam($callback, true))->toBeTrue(); }); +}); - test('enforces contracts on higher-order callables returning callables', function () { - /** @var callable(positive-int): (callable(non-empty-string): non-empty-string) $multiplierFactory */ - $multiplierFactory = function (int $multiplier): callable { - $inner = function (string $prefix) use ($multiplier): string { - if ($prefix === 'invalid') { - return ''; // Violates return non-empty-string - } - - return str_repeat($prefix, $multiplier); - }; - - return $inner; - }; +describe('Array & First-Class Callables', function () { + test('accepts valid instance method array callable', function () { + $service = new HelperService(); - $repeat3 = $multiplierFactory(3); + expect(testProcessArrayCallable([$service, 'formatUser']))->toBe('user_100'); + }); - expect($repeat3('abc'))->toBe('abcabcabc'); + test('accepts valid PHP 8.1+ first-class callable syntax', function () { + $service = new HelperService(); - expect(fn () => $multiplierFactory(-5)) - ->toThrow(TypeError::class, 'Variable $multiplierFactory: Callback argument #1') - ; + expect(testProcessArrayCallable($service->formatUser(...)))->toBe('user_100'); + }); +}); - expect(fn () => $repeat3('')) - ->toThrow(TypeError::class, 'argument #1') - ; +describe('Static Closures (static-closure)', function () { + test('accepts static closures and rejects non-static closures for static-closure', function () { + $staticClosure = static fn (int $id): string => "static_{$id}"; + expect(testStaticClosureParam($staticClosure))->toBe('static_100'); - expect(fn () => $repeat3('invalid')) - ->toThrow(TypeError::class, 'return value') - ; + $nonStaticClosure = fn (int $id): string => "bound_{$id}"; + expect(fn () => testStaticClosureParam($nonStaticClosure)) + ->toThrow(TypeError::class, 'must be a static Closure'); }); }); diff --git a/tests/Wrapper/CallableWrapperTest.php b/tests/Wrapper/CallableWrapperTest.php index 2ec865e..674508c 100644 --- a/tests/Wrapper/CallableWrapperTest.php +++ b/tests/Wrapper/CallableWrapperTest.php @@ -5,6 +5,7 @@ use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use TypePHP\Exception\TypeError; use TypePHP\Validator\TypeValidatorRegistry; use TypePHP\Wrapper\CallableWrapper; @@ -42,7 +43,7 @@ expect($wrapped(10))->toBe('id_10'); expect(fn () => $wrapped(-5)) - ->toThrow(TypeError::class, 'TestCallback argument #1') + ->toThrow(TypeError::class, 'TestCallback $id must be of type positive-int') ; }); @@ -61,4 +62,4 @@ ->toThrow(TypeError::class, 'must be a static Closure') ; }); -}); +}); \ No newline at end of file From 7d50b737de59c4900d8d24b131e554226f0e1681 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 21:27:34 +0800 Subject: [PATCH 07/10] Refactor error handling in GeneratorChecker to return ErrorMessage instead of throwing TypeError; enhance DocblockNormalizer to auto-complete return types for callables; add NestedAggregateService for testing; improve tests for GeneratorChecker and LazyIterators. --- .gitignore | 3 +- src/Internal/Checker/GeneratorChecker.php | 6 +- src/Internal/DocblockNormalizer.php | 4 +- src/Internal/ErrorFactory.php | 56 ++++-- .../Visitor/FunctionContractInjector.php | 2 +- src/Wrapper/IterableWrapper.php | 29 +-- .../Services/NestedAggregateService.php | 45 +++++ tests/Internal/DocblockNormalizerTest.php | 24 ++- tests/RuntimeChecker/GeneratorCheckerTest.php | 19 +- .../LazyIteratorsAndGeneratorsTest.php | 166 ++++++++++-------- tests/Wrapper/CallableWrapperTest.php | 2 +- 11 files changed, 236 insertions(+), 120 deletions(-) create mode 100644 tests/Fixtures/Services/NestedAggregateService.php diff --git a/.gitignore b/.gitignore index 4b7c5f3..0ce9616 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ docs/.vitepress/cache docs/.vitepress/dist /manual-tests -composer.lock \ No newline at end of file +composer.lock +index.php \ No newline at end of file diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index 455eaec..c0fda65 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -29,7 +29,7 @@ public static function checkSend(string $function, mixed $sendValue, TypeValidat if ($sendTypeNode !== null) { $err = $registry->validate($sendValue, $sendTypeNode, "$function(): Generator sent value (TSend)"); if ($err !== null) { - throw new \TypePHP\Exception\TypeError($err->getMessage()); + return $err; } } } @@ -64,14 +64,14 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty if ($key !== null && $keyTypeNode !== null) { $err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key"); if ($err !== null) { - throw new \TypePHP\Exception\TypeError($err->getMessage()); + return $err; } } if ($itemTypeNode !== null) { $err = $registry->validate($value, $itemTypeNode, "$function(): Return iterator value"); if ($err !== null) { - throw new \TypePHP\Exception\TypeError($err->getMessage()); + return $err; } } diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index b6b4c4c..b0d469c 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -29,7 +29,7 @@ final class DocblockNormalizer public static function normalize(string $doc): string { $doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc; - + $doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc; $doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc; if (! str_contains($doc, '{')) { @@ -52,4 +52,4 @@ function (array $matches): string { $doc ) ?? $doc; } -} +} \ No newline at end of file diff --git a/src/Internal/ErrorFactory.php b/src/Internal/ErrorFactory.php index 362a04b..3ca8c98 100644 --- a/src/Internal/ErrorFactory.php +++ b/src/Internal/ErrorFactory.php @@ -4,6 +4,10 @@ namespace TypePHP\Internal; +use ReflectionClass; +use Throwable; +use TypeError; + /** * @internal Factory creating ErrorMessage value objects and preparing TypeError instances with exact caller traces. */ @@ -23,22 +27,21 @@ public static function createError(string $message): ErrorMessage /** * Prepares a TypeError exception before throwing. - * For parameter and callback argument errors, it filters out internal library frames + * For parameter, callback, iterator, and generator errors, it filters out internal library frames * and sets the file and line to accurately blame the caller site. */ - public static function prepareException(\TypeError $e, ?int $line = null): \TypeError + public static function prepareException(TypeError $e, ?int $line = null): TypeError { - $ref = new \ReflectionObject($e); - - if ($line !== null && $ref->hasProperty('line')) { - $propLine = $ref->getProperty('line'); - $propLine->setValue($e, $line); - } + $targetFile = null; + $targetLine = $line; $message = $e->getMessage(); $isCallSiteError = str_contains($message, 'Argument $') || str_contains($message, 'argument #') - || str_contains($message, 'Callback argument'); + || str_contains($message, 'Callback ') + || str_contains($message, 'Iterator $') + || str_contains($message, 'Return iterator') + || str_contains($message, 'Generator sent value'); if ($isCallSiteError) { $trace = $e->getTrace(); @@ -47,15 +50,16 @@ public static function prepareException(\TypeError $e, ?int $line = null): \Type if (isset($frame['file'], $frame['line'])) { $file = str_replace('\\', '/', $frame['file']); - if (! str_contains($file, 'Internal/ErrorFactory.php') && ! str_contains($file, 'Wrapper/CallableWrapper.php')) { - if ($ref->hasProperty('file')) { - $propFile = $ref->getProperty('file'); - $propFile->setValue($e, $frame['file']); - } + $isInternal = str_contains($file, 'src/Internal/') + || str_contains($file, 'src/Wrapper/') + || str_contains($file, 'src/Validator/') + || str_contains($file, 'src/Resolver/') + || str_contains($file, 'src/Contract/'); - if ($line === null && $ref->hasProperty('line')) { - $propLine = $ref->getProperty('line'); - $propLine->setValue($e, $frame['line']); + if (! $isInternal) { + $targetFile = $frame['file']; + if ($targetLine === null) { + $targetLine = $frame['line']; } break; @@ -64,6 +68,22 @@ public static function prepareException(\TypeError $e, ?int $line = null): \Type } } + try { + $ref = new ReflectionClass(\Error::class); + + if ($targetFile !== null) { + $propFile = $ref->getProperty('file'); + $propFile->setValue($e, $targetFile); + } + + if ($targetLine !== null) { + $propLine = $ref->getProperty('line'); + $propLine->setValue($e, $targetLine); + } + } catch (Throwable $err) { + // Silently fallback if reflection mutation fails + } + return $e; } -} +} \ No newline at end of file diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index be5173f..3c248e3 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -49,7 +49,6 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; $hasThis = $isClassMethod && ! $node->isStatic(); - // Pass $this for instance methods, static::class for static methods, or null for global functions $thisArg = $hasThis ? new Node\Expr\Variable('this') : ($isClassMethod ? new Node\Expr\ClassConstFetch(new Node\Name('static'), 'class') : new Node\Expr\ConstFetch(new Node\Name('null'))); @@ -259,6 +258,7 @@ public function enterNode(Node $n): int|Node|null ] ) ), + new Node\Arg(new Node\Scalar\LNumber($n->getStartLine())), ] ) ), diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index a5839bc..3b9b08f 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -4,6 +4,7 @@ namespace TypePHP\Wrapper; +use Generator; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -20,16 +21,15 @@ final class IterableWrapper { /** * Wraps Traversable iterators and Generators to lazily validate keys and values during iteration. - * - * Performs the following steps: - * 1. Resolves key and item TypeNodes from contract metadata or aliases. - * 2. Constructs a callback to evaluate key and value type constraints. - * 3. Wraps Traversable objects with IteratorProxy for rewindability and method forwarding. - * 4. Wraps Generators with an interceptor generator to evaluate yielded items lazily. */ public static function wrap(string $function, string $paramName, mixed $iterable, TypeValidatorRegistry $registry): mixed { - if (! is_iterable($iterable) || \is_array($iterable)) { + if (! is_iterable($iterable)) { + return $iterable; + } + + // Preserve native PHP arrays for all standard function parameters! + if (\is_array($iterable) && $paramName !== 'return') { return $iterable; } @@ -56,7 +56,14 @@ public static function wrap(string $function, string $paramName, mixed $iterable $prefix = ($paramName === 'return') ? "$function(): Return iterator" : "$function(): Iterator \$$paramName"; $typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix); - if (! ($iterable instanceof \Generator)) { + // Wrap delegated yield from arrays in a lazy generator + if (\is_array($iterable)) { + return self::wrapGenerator((function () use ($iterable) { + yield from $iterable; + })(), $typeCheckCallback); + } + + if (! ($iterable instanceof Generator)) { return new IteratorProxy($iterable, $typeCheckCallback); } @@ -128,13 +135,13 @@ private static function createValidationCallback( * @param iterable $iterable * @param \Closure(mixed, mixed): void $typeCheckCallback * - * @return \Generator + * @return Generator */ - private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCallback): \Generator + private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCallback): Generator { foreach ($iterable as $key => $value) { $typeCheckCallback($key, $value); yield $key => $value; } } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Services/NestedAggregateService.php b/tests/Fixtures/Services/NestedAggregateService.php new file mode 100644 index 0000000..7a9367a --- /dev/null +++ b/tests/Fixtures/Services/NestedAggregateService.php @@ -0,0 +1,45 @@ + $items + */ + public function __construct( + private array $items = ['alpha' => 10, 'beta' => 20] + ) { + } + + public function getIterator(): Traversable + { + return new class ($this->items) implements IteratorAggregate { + public function __construct(private array $data) + { + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->data); + } + }; + } + + public function count(): int + { + return \count($this->items); + } + + public function getCustomMetadata(): string + { + return 'custom_metadata_string'; + } +} diff --git a/tests/Internal/DocblockNormalizerTest.php b/tests/Internal/DocblockNormalizerTest.php index 32969c8..a992f5f 100644 --- a/tests/Internal/DocblockNormalizerTest.php +++ b/tests/Internal/DocblockNormalizerTest.php @@ -10,6 +10,28 @@ expect(DocblockNormalizer::normalize($doc))->toBe($doc); }); + test('auto-completes omitted return types for callable and Closure signatures', function () { + $doc1 = '/** @var callable(int[] $items) $callback */'; + $expected1 = '/** @var callable(int[] $items): mixed $callback */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + + $doc2 = '/** @param Closure(string $name) $closure */'; + $expected2 = '/** @param Closure(string $name): mixed $closure */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + + $doc3 = '/** @param callable() $emptyCallable */'; + $expected3 = '/** @param callable(): mixed $emptyCallable */'; + expect(DocblockNormalizer::normalize($doc3))->toBe($expected3); + }); + + test('preserves existing return types on callable signatures untouched', function () { + $doc1 = '/** @param callable(int): string $cb */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($doc1); + + $doc2 = '/** @param Closure(int, string): bool $closure */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($doc2); + }); + test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () { $doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */'; $expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */'; @@ -104,4 +126,4 @@ expect(DocblockNormalizer::normalize($doc))->toBe($expected); }); -}); +}); \ No newline at end of file diff --git a/tests/RuntimeChecker/GeneratorCheckerTest.php b/tests/RuntimeChecker/GeneratorCheckerTest.php index f8c7275..78f7804 100644 --- a/tests/RuntimeChecker/GeneratorCheckerTest.php +++ b/tests/RuntimeChecker/GeneratorCheckerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use TypePHP\Internal\Checker\GeneratorChecker; +use TypePHP\Internal\ErrorMessage; use TypePHP\Validator\TypeValidatorRegistry; /** @@ -22,11 +23,13 @@ function sampleGeneratorFixture(): Generator expect($result)->toBe(10); }); - test('checkYield throws TypeError on invalid yielded value', function () { + test('checkYield returns ErrorMessage on invalid yielded value', function () { $registry = new TypeValidatorRegistry(); - expect(fn () => GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', -50, $registry)) - ->toThrow(TypeError::class, 'Return iterator value') + $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', -50, $registry); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('Return iterator value') ; }); @@ -38,11 +41,13 @@ function sampleGeneratorFixture(): Generator expect($result)->toBe(100); }); - test('checkSend throws TypeError on invalid TSend input value', function () { + test('checkSend returns ErrorMessage on invalid TSend input value', function () { $registry = new TypeValidatorRegistry(); - expect(fn () => GeneratorChecker::checkSend('sampleGeneratorFixture', -500, $registry)) - ->toThrow(TypeError::class, 'Generator sent value (TSend)') + $result = GeneratorChecker::checkSend('sampleGeneratorFixture', -500, $registry); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('Generator sent value (TSend)') ; }); -}); +}); \ No newline at end of file diff --git a/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php index 55f8941..d9fd9f9 100644 --- a/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php +++ b/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use TypePHP\Exception\TypeError; +use TypePHP\Tests\Fixtures\Services\NestedAggregateService; + /** * 1. Generator Parameter Contracts (@param Generator) * @@ -65,7 +68,7 @@ function testCountableTraversableParam(Traversable $items): int } /** - * 5. Generator Return Contracts (@return Generator) + * 5. Generator Return Contracts * * @return Generator */ @@ -73,9 +76,9 @@ function testGeneratorReturnContract(bool $yieldBadValue = false, bool $yieldBad { if ($yieldBadValue) { yield 'a' => 10; - yield 'b' => -99; // Invalid value (-99 is not positive-int) + yield 'b' => -99; // Invalid value } elseif ($yieldBadKey) { - yield '' => 10; // Invalid key (empty string) + yield '' => 10; // Invalid key } else { $receivedValue = yield 'a' => 10; yield 'b' => 20; @@ -83,7 +86,45 @@ function testGeneratorReturnContract(bool $yieldBadValue = false, bool $yieldBad } /** - * 6. Traversable / Iterator Return Contracts (@return Traversable) + * 6. Delegated yield from with Array + * + * @return Generator + */ +function testYieldFromArrayGenerator(bool $bad = false): Generator +{ + yield 'start' => 1; + yield from ($bad ? ['a' => 10, 'b' => -50] : ['a' => 10, 'b' => 20]); + yield 'end' => 99; +} + +/** + * 7. Delegated yield from with Nested Generator + * + * @return Generator + */ +function testYieldFromChildGenerator(bool $bad = false): Generator +{ + $child = function () use ($bad): Generator { + yield 'child_1' => 100; + yield ($bad ? '' : 'child_2') => 200; // Empty string violates non-empty-string key! + }; + + yield from $child(); +} + +/** + * 8. Complex Array Shapes in $gen->send() (TSend) + * + * @return Generator + */ +function testComplexShapeGenerator(): Generator +{ + $input = yield 1 => ['id' => 10, 'name' => 'Alice']; + yield 2 => ['id' => 20, 'name' => 'Bob']; // Valid TValue array shape! +} + +/** + * 9. Traversable Return Contracts * * @return Traversable */ @@ -105,7 +146,7 @@ function testTraversableReturnContract(array $data): Traversable test('throws TypeError lazily during iteration when generator yields bad value', function () { $badValueGen = function (): Generator { yield 'x' => 100; - yield 'y' => -50; // Invalid positive-int + yield 'y' => -50; }; $gen = $badValueGen(); @@ -117,7 +158,7 @@ function testTraversableReturnContract(array $data): Traversable test('throws TypeError lazily during iteration when generator yields bad key', function () { $badKeyGen = function (): Generator { - yield 123 => 100; // Int key instead of string + yield 123 => 100; }; $gen = $badKeyGen(); @@ -128,88 +169,64 @@ function testTraversableReturnContract(array $data): Traversable }); }); -describe('Lazy Non-Array Traversable Parameter Contracts (@param Traversable)', function () { - test('iterates valid ArrayIterator with non-empty-string keys and positive-int values', function () { - $iterator = new ArrayIterator([ - 'item1' => 10, - 'item2' => 20, - ]); - - expect(testTraversableParamContract($iterator))->toBe(['item1' => 10, 'item2' => 20]); - }); - - test('throws TypeError lazily during iteration when ArrayIterator has bad value', function () { - $badIterator = new ArrayIterator([ - 'item1' => 10, - 'item2' => -50, // -50 is not positive-int - ]); +describe('Delegated Generators (yield from)', function () { + test('lazily validates items yielded from delegated array', function () { + $gen = testYieldFromArrayGenerator(true); - expect(fn () => testTraversableParamContract($badIterator)) - ->toThrow(TypeError::class, 'Iterator $items value') - ; + expect(function () use ($gen) { + foreach ($gen as $k => $v) { + // Iteration throws on delegated 'b' => -50 + } + })->toThrow(TypeError::class, 'Return iterator value'); }); - test('throws TypeError lazily during iteration when ArrayIterator has bad key', function () { - $badKeyIterator = new ArrayIterator([ - '' => 10, // Empty string key - ]); + test('lazily validates items yielded from delegated child generator', function () { + $gen = testYieldFromChildGenerator(true); - expect(fn () => testTraversableParamContract($badKeyIterator)) - ->toThrow(TypeError::class, 'Iterator $items key') - ; + expect(function () use ($gen) { + foreach ($gen as $k => $v) { + // Iteration throws on child empty string key + } + })->toThrow(TypeError::class, 'Return iterator key'); }); }); -describe('Traversable Rewindability & Method Forwarding (IteratorProxy)', function () { - test('allows multiple foreach iterations over wrapped Traversable parameter', function () { - $iterator = new ArrayIterator(['a' => 10, 'b' => 20]); - expect(testMultipleIterationTraversableParam($iterator))->toBe(4); - }); - - test('forwards Countable interface and custom method calls to inner iterator', function () { - $arrayIterator = new ArrayIterator(['a' => 10, 'b' => 20]); - expect(testCountableTraversableParam($arrayIterator))->toBe(2); - }); -}); +describe('Complex Array Shapes in Generator TSend Input', function () { + test('accepts valid shape sent into generator via $gen->send()', function () { + $gen = testComplexShapeGenerator(); + $firstItem = $gen->current(); -describe('Lazy Generator & Traversable Return Contracts', function () { - test('iterates valid generator return cleanly', function () { - $result = []; - foreach (testGeneratorReturnContract(false, false) as $k => $v) { - $result[$k] = $v; - } + expect($firstItem)->toBe(['id' => 10, 'name' => 'Alice']); - expect($result)->toBe(['a' => 10, 'b' => 20]); + $gen->send(['action' => 'approve']); // Valid TSend shape + expect($gen->valid())->toBeTrue() + ->and($gen->current())->toBe(['id' => 20, 'name' => 'Bob']) + ; }); - test('throws TypeError lazily when returned generator yields invalid value', function () { - $gen = testGeneratorReturnContract(true, false); + test('throws TypeError when $gen->send() receives value violating TSend shape', function () { + $gen = testComplexShapeGenerator(); + $gen->current(); - expect(function () use ($gen) { - foreach ($gen as $k => $v) { - // Iteration throws when yielding 'b' => -99 - } - })->toThrow(TypeError::class, 'Return iterator value'); + expect(fn () => $gen->send(['action' => 'delete'])) // 'delete' violates 'approve'|'reject' + ->toThrow(TypeError::class, "Generator sent value (TSend)['action'] must be of type ('approve' | 'reject')") + ; }); +}); - test('throws TypeError lazily when returned generator yields invalid key', function () { - $gen = testGeneratorReturnContract(false, true); +describe('Multi-Level IteratorAggregate Unwrapping & Method Forwarding', function () { + test('unwraps nested IteratorAggregates and preserves method and count forwarding on proxy', function () { + $nestedService = new NestedAggregateService(['item1' => 10, 'item2' => 20]); - expect(function () use ($gen) { - foreach ($gen as $k => $v) { - // Iteration throws when yielding '' => 10 - } - })->toThrow(TypeError::class, 'Return iterator key'); + expect(testTraversableParamContract($nestedService))->toBe(['item1' => 10, 'item2' => 20]); + expect(testCountableTraversableParam($nestedService))->toBe(2); }); +}); - test('iterates valid ArrayIterator return cleanly', function () { - $iterator = testTraversableReturnContract(['item1' => 10, 'item2' => 20]); - $out = []; - foreach ($iterator as $k => $v) { - $out[$k] = $v; - } - - expect($out)->toBe(['item1' => 10, 'item2' => 20]); +describe('Traversable Rewindability & Return Contracts', function () { + test('allows multiple foreach iterations over wrapped Traversable parameter', function () { + $iterator = new ArrayIterator(['a' => 10, 'b' => 20]); + expect(testMultipleIterationTraversableParam($iterator))->toBe(4); }); test('throws TypeError lazily when returned ArrayIterator yields invalid element', function () { @@ -226,17 +243,16 @@ function testTraversableReturnContract(array $data): Traversable describe('Generator Input Validation ($gen->send() TSend Contract)', function () { test('accepts valid TSend value sent into generator', function () { $gen = testGeneratorReturnContract(false, false); - $gen->current(); // Reaches first yield - $gen->send(100); // 100 is positive-int (valid TSend) + $gen->current(); + $gen->send(100); expect($gen->valid())->toBeTrue(); }); test('throws TypeError when $gen->send() receives value violating TSend contract', function () { $gen = testGeneratorReturnContract(false, false); - $gen->current(); // Reaches first yield + $gen->current(); - // -500 violates positive-int TSend contract expect(fn () => $gen->send(-500)) ->toThrow(TypeError::class, 'Generator sent value (TSend)') ; diff --git a/tests/Wrapper/CallableWrapperTest.php b/tests/Wrapper/CallableWrapperTest.php index 674508c..8cdbe61 100644 --- a/tests/Wrapper/CallableWrapperTest.php +++ b/tests/Wrapper/CallableWrapperTest.php @@ -62,4 +62,4 @@ ->toThrow(TypeError::class, 'must be a static Closure') ; }); -}); \ No newline at end of file +}); From 4a0bf91fdb13f5844bceb1733517f08b17accfeb Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 22:04:20 +0800 Subject: [PATCH 08/10] Enhance ReturnChecker and IterableWrapper for improved type handling; add ClassUsingAliasedTraitMethod for trait method aliasing tests; update AdvancedEdgeCasesTest with nested conditional return type tests and trait method aliasing validation. --- src/Internal/Checker/ReturnChecker.php | 16 ++++-- src/Wrapper/IterableWrapper.php | 6 +-- .../Services/ClassUsingAliasedTraitMethod.php | 12 +++++ .../Boundaries/AdvancedEdgeCasesTest.php | 53 ++++++++++++++----- 4 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 5a1bca6..bd10dc6 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -124,9 +124,9 @@ public static function checkReturn(string $function, mixed $value, object|string if ($value instanceof \Traversable) { $baseName = ''; if ($returnTypeNode instanceof IdentifierTypeNode) { - $baseName = strtolower($returnTypeNode->name); + $baseName = strtolower(ltrim($returnTypeNode->name, '\\')); } elseif ($returnTypeNode instanceof GenericTypeNode) { - $baseName = strtolower($returnTypeNode->type->name); + $baseName = strtolower(ltrim($returnTypeNode->type->name, '\\')); } $standardIterables = ['iterable', 'traversable', 'iterator', 'generator', 'iteratoraggregate', 'array']; @@ -139,6 +139,8 @@ public static function checkReturn(string $function, mixed $value, object|string } /** + * Recursively resolves multi-branch nested conditional return types. + * * @param array $vars * @param array $boundTemplates */ @@ -159,7 +161,9 @@ private static function resolveConditionalReturnType( $isTargetMatch = ! $isTargetMatch; } - return $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + $selectedBranch = $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + + return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); } if ($returnTypeNode instanceof ConditionalTypeNode) { @@ -188,9 +192,11 @@ private static function resolveConditionalReturnType( $isTargetMatch = ! $isTargetMatch; } - return $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + $selectedBranch = $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + + return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); } return $returnTypeNode; } -} +} \ No newline at end of file diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 3b9b08f..b73859f 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -4,6 +4,7 @@ namespace TypePHP\Wrapper; +use ArrayIterator; use Generator; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -28,7 +29,6 @@ public static function wrap(string $function, string $paramName, mixed $iterable return $iterable; } - // Preserve native PHP arrays for all standard function parameters! if (\is_array($iterable) && $paramName !== 'return') { return $iterable; } @@ -40,9 +40,9 @@ public static function wrap(string $function, string $paramName, mixed $iterable if ($typeNode !== null) { $baseName = ''; if ($typeNode instanceof IdentifierTypeNode) { - $baseName = strtolower($typeNode->name); + $baseName = strtolower(ltrim($typeNode->name, '\\')); } elseif ($typeNode instanceof GenericTypeNode) { - $baseName = strtolower($typeNode->type->name); + $baseName = strtolower(ltrim($typeNode->type->name, '\\')); } $standardIterables = ['iterable', 'traversable', 'iterator', 'generator', 'iteratoraggregate', 'array']; diff --git a/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php b/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php new file mode 100644 index 0000000..adb3782 --- /dev/null +++ b/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php @@ -0,0 +1,12 @@ + recordAuditLog + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php index 7a4c30f..d678560 100644 --- a/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php +++ b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php @@ -6,7 +6,7 @@ use TypePHP\Tests\Fixtures\Generics\GenericCollection; /** - * 1. Function accepting a list of lazy callables + * Function accepting a list of lazy callables * * @param list $formatters */ @@ -20,6 +20,19 @@ function processFormatterList(array $formatters, int $id): array return $results; } +/** + * Multi-Branch Nested Conditional Return Type + * + * @param string $format + * @param mixed $value + * + * @return ($format is 'int' ? positive-int : ($format is 'float' ? positive-float : non-empty-string)) + */ +function testNestedConditionalReturn(string $format, mixed $value): mixed +{ + return $value; +} + describe('Advanced Edge-Case Behaviors', function () { describe('Skipped and Deeply Nested Array Destructuring with @var', function () { test('validates variables when skipping elements with empty commas in destructuring', function () { @@ -27,7 +40,7 @@ function processFormatterList(array $formatters, int $id): array * @var positive-int $id * @var non-empty-string $username */ - [$id, , $username] = [10, 'skipped_token', 'Alice']; + [$id,, $username] = [10, 'skipped_token', 'Alice']; expect($id)->toBe(10) ->and($username)->toBe('Alice') @@ -38,7 +51,7 @@ function processFormatterList(array $formatters, int $id): array * @var positive-int $id * @var non-empty-string $username */ - [$id, , $username] = [-5, 'skipped_token', 'Alice']; + [$id,, $username] = [-5, 'skipped_token', 'Alice']; })->toThrow(TypeError::class, 'Variable $id must be of type positive-int'); }); @@ -84,17 +97,16 @@ function processFormatterList(array $formatters, int $id): array /** @var GenericCollection $collection */ $collection = new GenericCollection(); - expect(fn () => $collection->add(-50)) - ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given') - ; + expect(fn() => $collection->add(-50)) + ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given'); }); }); describe('Collections of Lazy Callables', function () { test('executes and validates a list of lazy callable proxies', function () { $formatters = [ - fn (int $id): string => "id_{$id}", - fn (int $id): string => "user#{$id}", + fn(int $id): string => "id_{$id}", + fn(int $id): string => "user#{$id}", ]; $results = processFormatterList($formatters, 42); @@ -103,13 +115,28 @@ function processFormatterList(array $formatters, int $id): array test('throws TypeError when a callable in the collection returns an invalid type', function () { $formatters = [ - fn (int $id): string => "id_{$id}", - fn (int $id): string => '', + fn(int $id): string => "id_{$id}", + fn(int $id): string => '', ]; - expect(fn () => processFormatterList($formatters, 42)) - ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string') - ; + expect(fn() => processFormatterList($formatters, 42)) + ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string'); + }); + }); + + describe('Multi-Branch Nested Conditional Return Types', function () { + test('throws TypeError when return value violates nested conditional branch', function () { + expect(fn() => testNestedConditionalReturn('float', -5.5)) + ->toThrow(TypeError::class, 'Return value must be of type positive-float'); + }); + }); + + describe('Trait Method Aliasing (use Trait { old as new; })', function () { + test('inherits DocBlock contracts when a Trait method is aliased in a class', function () { + $service = new TypePHP\Tests\Fixtures\Services\ClassUsingAliasedTraitMethod(); + + expect(fn() => $service->recordAuditLog(-1, 'audit_ok')) + ->toThrow(TypeError::class, 'Argument $level must be of type positive-int'); }); }); }); From d8c35617b89f0bc63c4ecb12be3accd28db1d258 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 22:14:26 +0800 Subject: [PATCH 09/10] Refactor various files to ensure proper newline at end of file; enhance test cases for optional tuple parameters and lazy callable collections; improve error message assertions in AdvancedEdgeCasesTest. --- src/Internal/Checker/ReturnChecker.php | 2 +- src/Internal/DocblockNormalizer.php | 2 +- src/Internal/ErrorFactory.php | 2 +- src/Wrapper/IterableWrapper.php | 3 +- .../Services/ClassUsingAliasedTraitMethod.php | 2 +- tests/Internal/DocblockNormalizerTest.php | 2 +- tests/RuntimeChecker/GeneratorCheckerTest.php | 2 +- .../ArraysAndShapes/ArrayAndListTypesTest.php | 35 ++++++++++++++++++- .../Boundaries/AdvancedEdgeCasesTest.php | 28 ++++++++------- 9 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index bd10dc6..28e9ce6 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -199,4 +199,4 @@ private static function resolveConditionalReturnType( return $returnTypeNode; } -} \ No newline at end of file +} diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index b0d469c..f5ca2cb 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -52,4 +52,4 @@ function (array $matches): string { $doc ) ?? $doc; } -} \ No newline at end of file +} diff --git a/src/Internal/ErrorFactory.php b/src/Internal/ErrorFactory.php index 3ca8c98..ee68a3a 100644 --- a/src/Internal/ErrorFactory.php +++ b/src/Internal/ErrorFactory.php @@ -86,4 +86,4 @@ public static function prepareException(TypeError $e, ?int $line = null): TypeEr return $e; } -} \ No newline at end of file +} diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index b73859f..1d2fc58 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -4,7 +4,6 @@ namespace TypePHP\Wrapper; -use ArrayIterator; use Generator; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -144,4 +143,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php b/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php index adb3782..198d28b 100644 --- a/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php +++ b/tests/Fixtures/Services/ClassUsingAliasedTraitMethod.php @@ -9,4 +9,4 @@ class ClassUsingAliasedTraitMethod use ShiftedLoggerTrait { logEvent as recordAuditLog; // Aliases logEvent -> recordAuditLog } -} \ No newline at end of file +} diff --git a/tests/Internal/DocblockNormalizerTest.php b/tests/Internal/DocblockNormalizerTest.php index a992f5f..4bd15e8 100644 --- a/tests/Internal/DocblockNormalizerTest.php +++ b/tests/Internal/DocblockNormalizerTest.php @@ -126,4 +126,4 @@ expect(DocblockNormalizer::normalize($doc))->toBe($expected); }); -}); \ No newline at end of file +}); diff --git a/tests/RuntimeChecker/GeneratorCheckerTest.php b/tests/RuntimeChecker/GeneratorCheckerTest.php index 78f7804..5cd0047 100644 --- a/tests/RuntimeChecker/GeneratorCheckerTest.php +++ b/tests/RuntimeChecker/GeneratorCheckerTest.php @@ -50,4 +50,4 @@ function sampleGeneratorFixture(): Generator ->and($result->getMessage())->toContain('Generator sent value (TSend)') ; }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php index 5babcdb..188f9c7 100644 --- a/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php @@ -175,6 +175,38 @@ function testReturnKeylessTuple(bool $valid): array return [[10, 20], 'bundle']; } +/** + * Positional Tuple with Optional Trailing Element + * + * @param array{0: positive-int, 1?: non-empty-string} $tuple + */ +function testOptionalTupleParam(array $tuple): bool +{ + return true; +} + +describe('Positional Tuples with Optional Trailing Elements (array{0: T1, 1?: T2})', function () { + test('accepts tuple when optional trailing element is omitted', function () { + expect(testOptionalTupleParam([42]))->toBeTrue(); + }); + + test('accepts tuple when optional trailing element is provided with valid value', function () { + expect(testOptionalTupleParam([42, 'Alice']))->toBeTrue(); + }); + + test('throws TypeError when required first tuple item is invalid', function () { + expect(fn () => testOptionalTupleParam([-5])) + ->toThrow(TypeError::class, "['0'] must be of type positive-int") + ; + }); + + test('throws TypeError when optional second tuple item is provided with invalid value', function () { + expect(fn () => testOptionalTupleParam([42, ''])) + ->toThrow(TypeError::class, "['1'] must be of type non-empty-string") + ; + }); +}); + describe('Class Object Arrays (Dog[])', function () { test('accepts array of matching class instances', function () { expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2); @@ -433,7 +465,8 @@ function testReturnKeylessTuple(bool $valid): array ]; expect(fn () => testUnsealedTypedShape($payload)) - ->toThrow(TypeError::class, "['code'] must be of type string, int (999) given"); + ->toThrow(TypeError::class, "['code'] must be of type string, int (999) given") + ; }); }); }); diff --git a/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php index d678560..dedd218 100644 --- a/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php +++ b/tests/TypeChecking/Boundaries/AdvancedEdgeCasesTest.php @@ -97,16 +97,17 @@ function testNestedConditionalReturn(string $format, mixed $value): mixed /** @var GenericCollection $collection */ $collection = new GenericCollection(); - expect(fn() => $collection->add(-50)) - ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given'); + expect(fn () => $collection->add(-50)) + ->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given') + ; }); }); describe('Collections of Lazy Callables', function () { test('executes and validates a list of lazy callable proxies', function () { $formatters = [ - fn(int $id): string => "id_{$id}", - fn(int $id): string => "user#{$id}", + fn (int $id): string => "id_{$id}", + fn (int $id): string => "user#{$id}", ]; $results = processFormatterList($formatters, 42); @@ -115,19 +116,21 @@ function testNestedConditionalReturn(string $format, mixed $value): mixed test('throws TypeError when a callable in the collection returns an invalid type', function () { $formatters = [ - fn(int $id): string => "id_{$id}", - fn(int $id): string => '', + fn (int $id): string => "id_{$id}", + fn (int $id): string => '', ]; - expect(fn() => processFormatterList($formatters, 42)) - ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string'); + expect(fn () => processFormatterList($formatters, 42)) + ->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string') + ; }); }); describe('Multi-Branch Nested Conditional Return Types', function () { test('throws TypeError when return value violates nested conditional branch', function () { - expect(fn() => testNestedConditionalReturn('float', -5.5)) - ->toThrow(TypeError::class, 'Return value must be of type positive-float'); + expect(fn () => testNestedConditionalReturn('float', -5.5)) + ->toThrow(TypeError::class, 'Return value must be of type positive-float') + ; }); }); @@ -135,8 +138,9 @@ function testNestedConditionalReturn(string $format, mixed $value): mixed test('inherits DocBlock contracts when a Trait method is aliased in a class', function () { $service = new TypePHP\Tests\Fixtures\Services\ClassUsingAliasedTraitMethod(); - expect(fn() => $service->recordAuditLog(-1, 'audit_ok')) - ->toThrow(TypeError::class, 'Argument $level must be of type positive-int'); + expect(fn () => $service->recordAuditLog(-1, 'audit_ok')) + ->toThrow(TypeError::class, 'Argument $level must be of type positive-int') + ; }); }); }); From 35fec7a845589f0ec879f663cd9f5d72249377a5 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 22:22:58 +0800 Subject: [PATCH 10/10] Add documentation for trait inheritance and method aliasing; enhance examples for clarity --- docs/advanced/liskov-and-inheritance.md | 90 ++++++++++++++++++++----- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/docs/advanced/liskov-and-inheritance.md b/docs/advanced/liskov-and-inheritance.md index add5fe3..029bcad 100644 --- a/docs/advanced/liskov-and-inheritance.md +++ b/docs/advanced/liskov-and-inheritance.md @@ -205,7 +205,9 @@ $model->setTraitId(-50); AppModel::setTraitVersion(''); // Throws: TypeError: Property AppModel::$traitVersion must be of type non-empty-string ``` + --- + ## Trait Inheritance Across Parent-Child Classes When a parent class uses a Trait (`ParentClass` uses `LoggerTrait`), any child class extending the parent (`ChildClass extends ParentClass`) automatically inherits all `@param`, `@return`, and `@var` contracts declared on the parent's Trait: @@ -242,6 +244,43 @@ $child->logMessage(10, 'boot'); $child->logMessage(-50, 'boot'); // Throws: TypeError: ChildService::logMessage(): Argument $level must be of type positive-int ``` + +--- + +## Trait Method Aliasing (`use Trait { oldMethod as newMethod; }`) + +When a class uses a Trait and renames a method using PHP's trait `as` alias syntax, TypePHP inspects trait alias mappings and automatically inherits the original Trait method's DocBlock contracts onto the aliased method: + +```php +trait LoggerTrait +{ + /** + * @param positive-int $level + * @param non-empty-string $message + */ + public function logEvent(int $level, string $message): bool + { + return true; + } +} + +class AuditService +{ + use LoggerTrait { + logEvent as recordAuditLog; // Aliases method from trait! + } +} + +$service = new AuditService(); + +// Valid Call +$service->recordAuditLog(1, 'audit_ok'); + +// Invalid Call ($level = -1 violates inherited Trait's @param positive-int) +$service->recordAuditLog(-1, 'audit_ok'); +// Throws: TypeError: Argument $level must be of type positive-int +``` + --- ## Partial Parameter Overriding (Gap-Filling) @@ -292,42 +331,57 @@ $service->update(10, 'Charlie'); --- -## Parameter Renaming ($id → $userId) & Position Shifts +## Parameter Renaming ($id → $userId) & Position Shift Disambiguation -When a child class or attribute constructor overrides a parent method, parameter positions or parameter names may shift. TypePHP resolves parameter contract inheritance using **Name-First Resolution**: +When a child class, constructor, or trait implementation overrides an ancestor method, parameter positions may shift when new parameters are inserted, or parameter names may be renamed. -1. **Name Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$api`), the parent's contract is inherited by that parameter regardless of its position index in the child. -2. **Position Fallback:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP falls back to matching by position index. +TypePHP resolves parameter contract inheritance using **3-Tier Name & Position Disambiguation**: + +1. **Name-First Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$container`), the parent's contract is mapped to that parameter regardless of its position index in the child. +2. **Position Fallback on Renamed Parameters:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP maps the contract using its position index. +3. **Candidate Disambiguation (Shift Protection):** If a child class inserts a new parameter at index 0 (shifting all subsequent parameters down), TypePHP **verifies that the candidate child parameter does not already exist in the parent under its own name**. This prevents parent parameter contracts from accidentally mis-mapping onto shifted child parameters! ```php -class BaseField +class BaseRegistry { /** - * Parent constructor has $api at position #1 + * Parent constructor has 3 params: + * Index 0: $container + * Index 1: $definitions + * Index 2: $repositoryMap * - * @param string $type - * @param bool|array{admin-api: bool} $api + * @param array $definitions + * @param array $repositoryMap */ - public function __construct(string $type, bool|array $api = false) {} + public function __construct( + ContainerInterface $container, + array $definitions, + array $repositoryMap + ) {} } -class OneToManyRelation extends BaseField +class SalesChannelRegistry extends BaseRegistry { /** - * Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!) + * Child inserts $prefix at Index 0 (shifting $container to Index 1), + * and renames $definitions -> $definitionMap at Index 2! + * + * @param array $definitionMap + * @param array $repositoryMap */ public function __construct( - string $entity, - string $ref, - OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION, - bool|array $api = false + string $prefix, + ContainerInterface $container, + array $definitionMap, + array $repositoryMap ) { - parent::__construct('one-to-many', $api); + parent::__construct($container, $definitionMap, $repositoryMap); } } -// $onDelete (position #2 in child) is NOT overwritten by $api's type (position #1 in parent)! -$attr = new OneToManyRelation('unit', 'unit_id', OnDeleteOption::CASCADE, true); +// TypePHP correctly keeps $container (Index 1 in child) untouched, +// rather than mis-mapping parent's @param array $definitions (Index 1 in parent) onto it! +new SalesChannelRegistry('sales_channel.', new Container(), ['prod' => 'ProductDef'], ['prod' => 'ProductRepo']); ``` ---