From f0fb097f10863b20a50a376c61a73087a5c76791 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 17:38:48 +0800 Subject: [PATCH 01/23] Add ByRefService and ByRefServiceInterface with by-reference parameter methods --- tests/Fixtures/ByRef/ByRefService.php | 20 ++ .../Fixtures/ByRef/ByRefServiceInterface.php | 18 ++ .../Boundaries/ByReferenceParametersTest.php | 186 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 tests/Fixtures/ByRef/ByRefService.php create mode 100644 tests/Fixtures/ByRef/ByRefServiceInterface.php create mode 100644 tests/TypeChecking/Boundaries/ByReferenceParametersTest.php diff --git a/tests/Fixtures/ByRef/ByRefService.php b/tests/Fixtures/ByRef/ByRefService.php new file mode 100644 index 0000000..d3cb6d9 --- /dev/null +++ b/tests/Fixtures/ByRef/ByRefService.php @@ -0,0 +1,20 @@ + $statusCode and inherits @param positive-int &$code + public function incrementCode(int &$statusCode): void + { + $statusCode += 100; + } +} \ No newline at end of file diff --git a/tests/Fixtures/ByRef/ByRefServiceInterface.php b/tests/Fixtures/ByRef/ByRefServiceInterface.php new file mode 100644 index 0000000..3c7da7d --- /dev/null +++ b/tests/Fixtures/ByRef/ByRefServiceInterface.php @@ -0,0 +1,18 @@ + &$items + */ +function testByRefList(array &$items): void +{ + $items[] = 999; +} + +/** + * Variadic by-reference parameters + * + * @param positive-int &...$numbers + */ +function testByRefVariadic(int &...$numbers): void +{ + foreach ($numbers as &$num) { + $num += 10; + } +} + +/** + * String by-reference with trimming + * + * @param non-empty-string &$name + */ +function testByRefString(string &$name): void +{ + $name = trim($name); +} + +describe('Arguments Passed By-Reference (&$param)', function () { + describe('Scalar By-Reference Parameters', function () { + test('accepts valid positive-int by reference and preserves in-place caller mutation', function () { + $value = 25; + testByRefScalar($value); + + expect($value)->toBe(125); + }); + + test('throws TypeError when by-reference variable violates contract on function entry', function () { + $value = -50; + + expect(fn () => testByRefScalar($value)) + ->toThrow(TypeError::class, 'positive-int'); + + // Value must remain untouched in caller scope + expect($value)->toBe(-50); + }); + + test('handles native by-reference parameter when docblock omits & prefix in @param tag', function () { + $counter = 15; + testByRefNativeOnly($counter); + + expect($counter)->toBe(30); + + $invalidCounter = -10; + expect(fn () => testByRefNativeOnly($invalidCounter)) + ->toThrow(TypeError::class, 'positive-int'); + + expect($invalidCounter)->toBe(-10); + }); + + test('validates non-empty-string by reference and preserves in-place modification', function () { + $name = ' Alice '; + testByRefString($name); + + expect($name)->toBe('Alice'); + + $emptyName = ''; + expect(fn () => testByRefString($emptyName)) + ->toThrow(TypeError::class, 'non-empty-string'); + + expect($emptyName)->toBe(''); + }); + }); + + describe('Array / List By-Reference Parameters', function () { + test('accepts valid list by reference and mutates array in caller scope', function () { + $list = [10, 20, 30]; + testByRefList($list); + + expect($list)->toBe([10, 20, 30, 999]); + }); + + test('throws TypeError when by-reference list contains invalid items on entry', function () { + $list = [10, -20, 30]; + + expect(fn () => testByRefList($list)) + ->toThrow(TypeError::class, 'positive-int'); + + // Original array remains untouched + expect($list)->toBe([10, -20, 30]); + }); + }); + + describe('Variadic By-Reference Parameters (&...$params)', function () { + test('accepts multiple variables by reference and mutates all of them in caller scope', function () { + $a = 1; + $b = 2; + $c = 3; + + testByRefVariadic($a, $b, $c); + + expect($a)->toBe(11) + ->and($b)->toBe(12) + ->and($c)->toBe(13); + }); + + test('throws TypeError when any variadic by-reference argument is invalid on entry', function () { + $a = 1; + $b = -5; + $c = 3; + + expect(fn () => testByRefVariadic($a, $b, $c)) + ->toThrow(TypeError::class, 'positive-int'); + + // None of the variables should have mutated + expect($a)->toBe(1) + ->and($b)->toBe(-5) + ->and($c)->toBe(3); + }); + }); + + describe('OOP & Interface Inherited By-Reference Parameters', function () { + test('inherits by-reference parameter contract from interface and mutates caller variable', function () { + $service = new ByRefService(); + $status = 'active'; + + $service->updateStatus($status); + + expect($status)->toBe('ACTIVE'); + }); + + test('throws TypeError when inherited by-reference parameter receives invalid value', function () { + $service = new ByRefService(); + $status = ''; + + expect(fn () => $service->updateStatus($status)) + ->toThrow(TypeError::class, 'non-empty-string'); + + expect($status)->toBe(''); + }); + + test('inherits by-reference contract across renamed parameter ($code -> $statusCode)', function () { + $service = new ByRefService(); + $code = 50; + + $service->incrementCode($code); + expect($code)->toBe(150); + + $badCode = -10; + expect(fn () => $service->incrementCode($badCode)) + ->toThrow(TypeError::class, 'positive-int'); + + expect($badCode)->toBe(-10); + }); + }); +}); \ No newline at end of file From 788583c395e155040de3522b29367bdcd2d44daa Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 17:40:07 +0800 Subject: [PATCH 02/23] Add UnpackService and ArgumentUnpackingTest for argument unpacking functionality --- tests/Fixtures/Unpack/UnpackService.php | 32 ++++ .../Boundaries/ArgumentUnpackingTest.php | 150 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/Fixtures/Unpack/UnpackService.php create mode 100644 tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php diff --git a/tests/Fixtures/Unpack/UnpackService.php b/tests/Fixtures/Unpack/UnpackService.php new file mode 100644 index 0000000..5e1764d --- /dev/null +++ b/tests/Fixtures/Unpack/UnpackService.php @@ -0,0 +1,32 @@ + $id, + 'username' => $username, + 'role' => $role, + 'active' => $active, + ]; + } + + /** + * @param positive-int ...$scores + */ + public function sumScores(int ...$scores): int + { + return array_sum($scores); + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php b/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php new file mode 100644 index 0000000..4157e89 --- /dev/null +++ b/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php @@ -0,0 +1,150 @@ + $age + */ +function testUnpackFunction(int $id, string $name, int $age): array +{ + return ['id' => $id, 'name' => $name, 'age' => $age]; +} + +/** + * @param positive-int ...$ids + */ +function testVariadicUnpackFunction(int ...$ids): int +{ + return count($ids); +} + +describe('PHP 8.0+ Argument Unpacking / Spread (...$args)', function () { + describe('Standalone Functions with Argument Unpacking', function () { + test('accepts unpacked associative array with named keys in swapped order', function () { + $payload = [ + 'age' => 25, + 'name' => 'Alice', + 'id' => 100, + ]; + + $result = testUnpackFunction(...$payload); + + expect($result)->toBe([ + 'id' => 100, + 'name' => 'Alice', + 'age' => 25, + ]); + }); + + test('accepts mixed positional and unpacked named arguments', function () { + $extra = [ + 'age' => 30, + 'name' => 'Bob', + ]; + + $result = testUnpackFunction(42, ...$extra); + + expect($result)->toBe([ + 'id' => 42, + 'name' => 'Bob', + 'age' => 30, + ]); + }); + + test('throws TypeError when unpacked argument violates parameter contract', function () { + $badPayload = [ + 'age' => 25, + 'name' => 'Alice', + 'id' => -10, // Violates positive-int + ]; + + expect(fn () => testUnpackFunction(...$badPayload)) + ->toThrow(TypeError::class, 'Argument $id must be of type positive-int'); + }); + + test('throws TypeError when unpacked int range argument exceeds max bound', function () { + $badAgePayload = [ + 'id' => 10, + 'name' => 'Alice', + 'age' => 150, // Violates int<1, 100> + ]; + + expect(fn () => testUnpackFunction(...$badAgePayload)) + ->toThrow(TypeError::class, 'Argument $age'); + }); + + test('throws TypeError when unpacked string argument is empty', function () { + $badNamePayload = [ + 'id' => 10, + 'name' => '', // Violates non-empty-string + 'age' => 25, + ]; + + expect(fn () => testUnpackFunction(...$badNamePayload)) + ->toThrow(TypeError::class, 'Argument $name must be of type non-empty-string'); + }); + }); + + describe('Variadic Parameter Unpacking (...$ids)', function () { + test('accepts valid unpacked positional list into variadic parameter', function () { + $ids = [10, 20, 30, 40]; + + expect(testVariadicUnpackFunction(...$ids))->toBe(4); + }); + + test('throws TypeError when unpacked variadic list contains an invalid element', function () { + $ids = [10, 20, -5, 40]; + + expect(fn () => testVariadicUnpackFunction(...$ids)) + ->toThrow(TypeError::class, 'Argument $ids[2] must be of type positive-int'); + }); + }); + + describe('Class Method Argument Unpacking', function () { + test('accepts unpacked named arguments on class method', function () { + $service = new UnpackService(); + $params = [ + 'username' => 'alice_admin', + 'role' => 'admin', + 'id' => 1, + 'active' => true, + ]; + + $result = $service->configureUser(...$params); + + expect($result)->toBe([ + 'id' => 1, + 'username' => 'alice_admin', + 'role' => 'admin', + 'active' => true, + ]); + }); + + test('throws TypeError when unpacked role on class method violates literal union', function () { + $service = new UnpackService(); + $params = [ + 'id' => 1, + 'username' => 'alice_admin', + 'role' => 'superadmin', // Violates 'admin'|'editor'|'viewer' + ]; + + expect(fn () => $service->configureUser(...$params)) + ->toThrow(TypeError::class, "Argument \$role must be of type ('admin' | 'editor' | 'viewer')"); + }); + + test('accepts unpacked variadic integers on class method', function () { + $service = new UnpackService(); + $scores = [100, 200, 300]; + + expect($service->sumScores(...$scores))->toBe(600); + + $badScores = [100, -50, 300]; + expect(fn () => $service->sumScores(...$badScores)) + ->toThrow(TypeError::class, 'Argument $scores[1] must be of type positive-int'); + }); + }); +}); \ No newline at end of file From fda9fa1fd068277ae979ffcc9144ea0327c256e7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 17:42:42 +0800 Subject: [PATCH 03/23] Add UnsealedPayloadService and ComplexUnsealedShapesTest for dynamic shape processing --- .../Shapes/UnsealedPayloadService.php | 28 +++++ .../ComplexUnsealedShapesTest.php | 117 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/Fixtures/Shapes/UnsealedPayloadService.php create mode 100644 tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php diff --git a/tests/Fixtures/Shapes/UnsealedPayloadService.php b/tests/Fixtures/Shapes/UnsealedPayloadService.php new file mode 100644 index 0000000..7af5e67 --- /dev/null +++ b/tests/Fixtures/Shapes/UnsealedPayloadService.php @@ -0,0 +1,28 @@ + values + * + * @param array{id: positive-int, ...>} $payload + */ + public function processBatchOptions(array $payload): int + { + return count($payload); + } + + /** + * Unsealed shape allowing dynamic extra keys with nested array shape values + * + * @param array{version: non-empty-string, ...} $data + */ + public function processPlayerStats(array $data): int + { + return count($data); + } +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php b/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php new file mode 100644 index 0000000..c4da18a --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php @@ -0,0 +1,117 @@ +>} $config + */ +function testComplexUnsealedFunction(array $config): bool +{ + return true; +} + +describe('Complex Unsealed Array Shapes (...)', function () { + describe('Unsealed Shapes with Nested Lists (...>)', function () { + test('accepts required keys and valid extra list items', function () { + $service = new UnsealedPayloadService(); + $payload = [ + 'id' => 10, + 'even_scores' => [2, 4, 6], + 'odd_scores' => [1, 3, 5], + ]; + + expect($service->processBatchOptions($payload))->toBe(3); + }); + + test('accepts required keys with empty extra keys', function () { + $service = new UnsealedPayloadService(); + + expect($service->processBatchOptions(['id' => 42]))->toBe(1); + }); + + test('throws TypeError when an extra key contains an invalid integer in the nested list', function () { + $service = new UnsealedPayloadService(); + $badPayload = [ + 'id' => 10, + 'even_scores' => [2, 4, 6], + 'odd_scores' => [1, -3, 5], // -3 violates positive-int inside the list! + ]; + + expect(fn () => $service->processBatchOptions($badPayload)) + ->toThrow(TypeError::class, "['odd_scores'][1] must be of type positive-int"); + }); + + test('throws TypeError when an extra key is not a list', function () { + $service = new UnsealedPayloadService(); + $badPayload = [ + 'id' => 10, + 'extra_info' => 'not_a_list', // String instead of list + ]; + + expect(fn () => $service->processBatchOptions($badPayload)) + ->toThrow(TypeError::class, "['extra_info'] must be a list"); + }); + }); + + describe('Unsealed Shapes with Nested Sub-Shapes (...)', function () { + test('accepts required keys and valid extra nested sub-shapes', function () { + $service = new UnsealedPayloadService(); + $data = [ + 'version' => '2.1.0', + 'player_one' => ['score' => 1500, 'active' => true], + 'player_two' => ['score' => 2400, 'active' => false], + ]; + + expect($service->processPlayerStats($data))->toBe(3); + }); + + test('throws TypeError when an extra sub-shape item violates inner scalar constraint', function () { + $service = new UnsealedPayloadService(); + $badData = [ + 'version' => '2.1.0', + 'player_one' => ['score' => 1500, 'active' => true], + 'player_two' => ['score' => -50, 'active' => false], // -50 violates positive-int in sub-shape! + ]; + + expect(fn () => $service->processPlayerStats($badData)) + ->toThrow(TypeError::class, "['player_two']['score'] must be of type positive-int"); + }); + + test('throws TypeError when an extra sub-shape is missing a required inner property', function () { + $service = new UnsealedPayloadService(); + $badData = [ + 'version' => '2.1.0', + 'player_one' => ['score' => 1500], // Missing required 'active' boolean! + ]; + + expect(fn () => $service->processPlayerStats($badData)) + ->toThrow(TypeError::class, "['player_one'] is missing required key 'active'"); + }); + }); + + describe('Standalone Functions with Unsealed list', function () { + test('accepts valid nested non-empty-string lists under arbitrary extra keys', function () { + $config = [ + 'id' => 100, + 'tags' => ['php', 'typephp'], + 'flags' => ['strict', 'runtime'], + ]; + + expect(testComplexUnsealedFunction($config))->toBeTrue(); + }); + + test('throws TypeError when extra list contains empty string', function () { + $config = [ + 'id' => 100, + 'tags' => ['php', ''], // Empty string violates non-empty-string + ]; + + expect(fn () => testComplexUnsealedFunction($config)) + ->toThrow(TypeError::class, "['tags'][1] must be of type non-empty-string"); + }); + }); +}); \ No newline at end of file From e78b3a834efdbb5f780df446becf2810441ac0e8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:04:08 +0800 Subject: [PATCH 04/23] Add CollisionService and related traits for method conflict resolution testing --- tests/Fixtures/Traits/CollisionService.php | 13 +++++++++ tests/Fixtures/Traits/FirstLogger.php | 19 +++++++++++++ tests/Fixtures/Traits/SecondLogger.php | 19 +++++++++++++ .../TraitConflictResolutionTest.php | 28 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 tests/Fixtures/Traits/CollisionService.php create mode 100644 tests/Fixtures/Traits/FirstLogger.php create mode 100644 tests/Fixtures/Traits/SecondLogger.php create mode 100644 tests/TypeChecking/InheritanceAndAttributes/TraitConflictResolutionTest.php diff --git a/tests/Fixtures/Traits/CollisionService.php b/tests/Fixtures/Traits/CollisionService.php new file mode 100644 index 0000000..0a7bb35 --- /dev/null +++ b/tests/Fixtures/Traits/CollisionService.php @@ -0,0 +1,13 @@ +log(10, 'app_boot'))->toBe('first: 10 - app_boot'); + + expect(fn () => $service->log(-5, 'app_boot')) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $service->log(10, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('enforces docblock contracts of the aliased trait method (as backupLog)', function () { + $service = new CollisionService(); + + expect($service->backupLog(-20, 'backup_msg'))->toBe('second: -20 - backup_msg'); + + expect(fn () => $service->backupLog(20, 'backup_msg')) + ->toThrow(TypeError::class, 'negative-int'); + }); +}); \ No newline at end of file From bda8e3b323f2e8358f67442ee1b30ae443732a8c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:04:14 +0800 Subject: [PATCH 05/23] Add trait alias caching and retrieval in HierarchyResolver for improved performance --- src/Contract/HierarchyResolver.php | 37 ++++++++++++++++++++++++-- src/Internal/Checker/ParamChecker.php | 21 ++++++++++++++- src/Internal/Checker/ReturnChecker.php | 21 ++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/Contract/HierarchyResolver.php b/src/Contract/HierarchyResolver.php index e258463..703fb2d 100644 --- a/src/Contract/HierarchyResolver.php +++ b/src/Contract/HierarchyResolver.php @@ -26,6 +26,13 @@ final class HierarchyResolver */ private static array $classHierarchyCache = []; + /** + * In-memory cache for class trait aliases. + * + * @var array> + */ + private static array $traitAliasCache = []; + /** * Resets the hierarchy cache. Useful for test isolation. */ @@ -33,6 +40,32 @@ public static function reset(): void { self::$methodHierarchyCache = []; self::$classHierarchyCache = []; + self::$traitAliasCache = []; + } + + /** + * Returns cached trait aliases for a given class. + * + * @return array + */ + public static function getTraitAliases(string $className): array + { + if (isset(self::$traitAliasCache[$className])) { + return self::$traitAliasCache[$className]; + } + + if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { + return self::$traitAliasCache[$className] = []; + } + + try { + /** @var class-string $className */ + $ref = new ReflectionClass($className); + + return self::$traitAliasCache[$className] = $ref->getTraitAliases(); + } catch (\Throwable $e) { + return self::$traitAliasCache[$className] = []; + } } /** @@ -53,7 +86,7 @@ public static function getMethodHierarchy(ReflectionMethod $ref): array $targetClass = new ReflectionClass($targetClassName); - $traitAliases = $targetClass->getTraitAliases(); + $traitAliases = self::getTraitAliases($targetClassName); if (isset($traitAliases[$methodName])) { [$traitName, $originalMethodName] = explode('::', $traitAliases[$methodName], 2); if (trait_exists($traitName)) { @@ -131,4 +164,4 @@ public static function getClassHierarchy(ReflectionClass $ref): array return self::$classHierarchyCache[$cacheKey] = $hierarchy; } -} +} \ No newline at end of file diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 2528cfd..c67f87d 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -10,6 +10,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Contract\HierarchyResolver; use TypePHP\Internal\ClassNameValidator; use TypePHP\Internal\Config; use TypePHP\Internal\ErrorFactory; @@ -43,6 +44,24 @@ public static function checkParams(string $function, array $vars, object|string| if ($actualClassName !== null && $actualClassName !== $classOrTrait) { $effectiveFunction = $actualClassName . '::' . $methodName; } + + if ($thisObj !== null) { + $targetClass = $actualClassName ?? $classOrTrait; + $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + + if (\count($traitAliases) > 0) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + foreach ($trace as $frame) { + $frameFunc = $frame['function'] ?? ''; + $frameClass = $frame['class'] ?? ''; + if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { + $effectiveFunction = $targetClass . '::' . $frameFunc; + + break; + } + } + } + } } $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); @@ -358,4 +377,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 28e9ce6..3adae41 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -10,6 +10,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Contract\HierarchyResolver; use TypePHP\Internal\ClassNameValidator; use TypePHP\Internal\Config; use TypePHP\Resolver\SpecialTypeResolver; @@ -40,6 +41,24 @@ public static function checkReturn(string $function, mixed $value, object|string if ($actualClassName !== null && $actualClassName !== $classOrTrait) { $effectiveFunction = $actualClassName . '::' . $methodName; } + + if ($thisObj !== null) { + $targetClass = $actualClassName ?? $classOrTrait; + $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + + if (\count($traitAliases) > 0) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + foreach ($trace as $frame) { + $frameFunc = $frame['function'] ?? ''; + $frameClass = $frame['class'] ?? ''; + if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { + $effectiveFunction = $targetClass . '::' . $frameFunc; + + break; + } + } + } + } } $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); @@ -199,4 +218,4 @@ private static function resolveConditionalReturnType( return $returnTypeNode; } -} +} \ No newline at end of file From ccb60256934c84bb37f426204426ee4f053887c7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:22:16 +0800 Subject: [PATCH 06/23] Add ReadonlyUser and UninitializedReadonlyContainer classes with tests for readonly properties --- tests/Fixtures/Readonly/ReadonlyUser.php | 24 +++++ .../UninitializedReadonlyContainer.php | 29 ++++++ .../Boundaries/ReadonlyPropertiesTest.php | 89 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 tests/Fixtures/Readonly/ReadonlyUser.php create mode 100644 tests/Fixtures/Readonly/UninitializedReadonlyContainer.php create mode 100644 tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php diff --git a/tests/Fixtures/Readonly/ReadonlyUser.php b/tests/Fixtures/Readonly/ReadonlyUser.php new file mode 100644 index 0000000..e0eec31 --- /dev/null +++ b/tests/Fixtures/Readonly/ReadonlyUser.php @@ -0,0 +1,24 @@ +id = $id; + $this->username = $username; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php b/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php new file mode 100644 index 0000000..3cb37ce --- /dev/null +++ b/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php @@ -0,0 +1,29 @@ +id = $id; + $this->name = $name; + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php new file mode 100644 index 0000000..a703cb5 --- /dev/null +++ b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php @@ -0,0 +1,89 @@ +id)->toBe(42) + ->and($user->username)->toBe('Alice'); + }); + + test('throws TypeError when initializing readonly property with invalid integer', function () { + expect(fn () => new ReadonlyUser(-5, 'Alice')) + ->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError when initializing readonly property with empty string', function () { + expect(fn () => new ReadonlyUser(42, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); + + describe('Promoted Readonly Constructor Parameters (PHP 8.1+)', function () { + test('instantiates class with valid promoted readonly properties', function () { + $order = new ReadonlyOrder(100, 'SKU-500', 5); + + expect($order->orderId)->toBe(100) + ->and($order->sku)->toBe('SKU-500') + ->and($order->quantity)->toBe(5); + }); + + test('throws TypeError when promoted readonly orderId violates positive-int', function () { + expect(fn () => new ReadonlyOrder(-1, 'SKU-500', 5)) + ->toThrow(TypeError::class, 'Argument $orderId must be of type positive-int'); + }); + + test('throws TypeError when promoted readonly sku violates non-empty-string', function () { + expect(fn () => new ReadonlyOrder(100, '', 5)) + ->toThrow(TypeError::class, 'Argument $sku must be of type non-empty-string'); + }); + + test('throws TypeError when promoted readonly quantity exceeds max bound of int<1, 100>', function () { + expect(fn () => new ReadonlyOrder(100, 'SKU-500', 250)) + ->toThrow(TypeError::class, 'Argument $quantity'); + }); + }); + + describe('Uninitialized Readonly Properties & Object Shapes', function () { + test('safely rejects uninitialized readonly object without crashing PHP engine', function () { + $uninitialized = new UninitializedReadonlyContainer(); + + expect(fn () => testObjectShapeOnReadonly($uninitialized)) + ->toThrow(TypeError::class, "property 'id' is uninitialized"); + }); + + test('validates and accepts readonly container once initialized', function () { + $container = new UninitializedReadonlyContainer(); + $container->initialize(10, 'Report'); + + expect(testObjectShapeOnReadonly($container))->toBeTrue(); + }); + + test('throws TypeError when deferred readonly initialization receives invalid value', function () { + $container = new UninitializedReadonlyContainer(); + + expect(fn () => $container->initialize(-50, 'Report')) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $container->initialize(10, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); +}); \ No newline at end of file From f5a9093c1f95e440b6fb60765ad161aa2ab8193a Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:22:23 +0800 Subject: [PATCH 07/23] Add AnonymousContractInterface and ReadonlyOrder classes with tests for anonymous class contracts --- .../Anonymous/AnonymousContractInterface.php | 16 +++ tests/Fixtures/Readonly/ReadonlyOrder.php | 22 +++ .../AnonymousClassContractsTest.php | 133 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 tests/Fixtures/Anonymous/AnonymousContractInterface.php create mode 100644 tests/Fixtures/Readonly/ReadonlyOrder.php create mode 100644 tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php diff --git a/tests/Fixtures/Anonymous/AnonymousContractInterface.php b/tests/Fixtures/Anonymous/AnonymousContractInterface.php new file mode 100644 index 0000000..9d02973 --- /dev/null +++ b/tests/Fixtures/Anonymous/AnonymousContractInterface.php @@ -0,0 +1,16 @@ + $quantity + */ + public function __construct( + public readonly int $orderId, + public readonly string $sku, + public readonly int $quantity = 1 + ) { + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php new file mode 100644 index 0000000..d668852 --- /dev/null +++ b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php @@ -0,0 +1,133 @@ +generateCode(42, 'ITEM'))->toBe('ITEM_42'); + + expect(fn () => $service->generateCode(-5, 'ITEM')) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $service->generateCode(42, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('validates return contracts when anonymous class method returns invalid value', function () { + $service = new class () { + /** + * @param positive-int $id + * + * @return non-empty-string + */ + public function badReturn(int $id): string + { + return ''; + } + }; + + expect(fn () => $service->badReturn(10)) + ->toThrow(TypeError::class, 'Return value'); + }); + }); + + describe('Anonymous Class Interface Contract Inheritance (LSP)', function () { + test('inherits parameter and return shape contracts from interface without local docblocks', function () { + $service = new class () implements AnonymousContractInterface { + public function formatUser(int $id, string $name): array + { + return ['id' => $id, 'name' => $name]; + } + }; + + expect($service->formatUser(100, 'Alice'))->toBe(['id' => 100, 'name' => 'Alice']); + + expect(fn () => $service->formatUser(-1, 'Alice')) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $service->formatUser(100, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('inherits by-reference parameter contracts on anonymous class implementing interface', function () { + $service = new class () implements ByRefServiceInterface { + public function updateStatus(string &$status): void + { + $status = strtoupper($status); + } + + public function incrementCode(int &$code): void + { + $code += 50; + } + }; + + $status = 'pending'; + $service->updateStatus($status); + expect($status)->toBe('PENDING'); + + $badStatus = ''; + expect(fn () => $service->updateStatus($badStatus)) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); + + describe('Anonymous Class Extending Parent Classes', function () { + test('inherits parent class method contracts in anonymous child class', function () { + $service = new class () extends BaseService { + public function find(int $id): array + { + return parent::find($id); + } + }; + + expect($service->find(10))->toBe(['id' => 10, 'name' => 'Alice']); + + expect(fn () => $service->find(-5)) + ->toThrow(TypeError::class, 'positive-int'); + }); + }); + + describe('Anonymous Class Properties with @var Annotations', function () { + test('validates property assignments on anonymous class', function () { + $container = new class () { + /** + * @var positive-int + */ + public int $count = 10; + + /** + * @var non-empty-string + */ + public string $title = 'Initial'; + }; + + $container->count = 50; + expect($container->count)->toBe(50); + + expect(fn () => $container->count = -10) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $container->title = '') + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); +}); \ No newline at end of file From 511d9a8865b3eebc970ecb71c29239a8c1a46d82 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:25:49 +0800 Subject: [PATCH 08/23] Add Suit and TransactionStatus enums with tests for key-of and value-of functionality --- tests/Fixtures/Enums/Suit.php | 13 +++ tests/Fixtures/Enums/TransactionStatus.php | 12 +++ .../ArraysAndShapes/EnumKeyOfValueOfTest.php | 85 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 tests/Fixtures/Enums/Suit.php create mode 100644 tests/Fixtures/Enums/TransactionStatus.php create mode 100644 tests/TypeChecking/ArraysAndShapes/EnumKeyOfValueOfTest.php diff --git a/tests/Fixtures/Enums/Suit.php b/tests/Fixtures/Enums/Suit.php new file mode 100644 index 0000000..9c9c054 --- /dev/null +++ b/tests/Fixtures/Enums/Suit.php @@ -0,0 +1,13 @@ + $suitName + */ +function testUnitEnumKeyOf(string $suitName): string +{ + return $suitName; +} + +/** + * @param value-of $suitValue + */ +function testUnitEnumValueOf(mixed $suitValue): mixed +{ + return $suitValue; +} + +/** + * @param key-of $statusName + */ +function testIntBackedEnumKeyOf(string $statusName): string +{ + return $statusName; +} + +/** + * @param value-of $statusCode + */ +function testIntBackedEnumValueOf(int $statusCode): int +{ + return $statusCode; +} + +describe('key-of and value-of with UnitEnums and Int BackedEnums', function () { + describe('Pure UnitEnums (key-of vs value-of)', function () { + test('key-of accepts exact case names', function () { + expect(testUnitEnumKeyOf('Hearts'))->toBe('Hearts'); + expect(testUnitEnumKeyOf('Spades'))->toBe('Spades'); + }); + + test('key-of rejects invalid case names and lowercase names', function () { + expect(fn () => testUnitEnumKeyOf('hearts')) + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit'); + + expect(fn () => testUnitEnumKeyOf('InvalidSuit')) + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit'); + }); + + test('value-of throws TypeError because UnitEnums have no backing values', function () { + expect(fn () => testUnitEnumValueOf('Hearts')) + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit'); + + expect(fn () => testUnitEnumValueOf(1)) + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit'); + }); + }); + + describe('Integer BackedEnums (key-of vs value-of)', function () { + test('key-of accepts case names', function () { + expect(testIntBackedEnumKeyOf('PENDING'))->toBe('PENDING'); + expect(testIntBackedEnumKeyOf('COMPLETED'))->toBe('COMPLETED'); + }); + + test('key-of rejects invalid case names', function () { + expect(fn () => testIntBackedEnumKeyOf('pending')) + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus'); + }); + + test('value-of accepts backing integers', function () { + expect(testIntBackedEnumValueOf(1))->toBe(1); + expect(testIntBackedEnumValueOf(2))->toBe(2); + }); + + test('value-of rejects non-existent integers and string numbers', function () { + expect(fn () => testIntBackedEnumValueOf(99)) + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus'); + }); + }); +}); \ No newline at end of file From bf38954bd9589bad83801d752ddaad8b36b984a4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:39:03 +0800 Subject: [PATCH 09/23] Add ConditionalReturnService and MultiBranchConditionalReturnsTest for complex return type handling --- src/Validator/GenericValidator.php | 25 +++-- .../Conditionals/ConditionalReturnService.php | 52 +++++++++ .../MultiBranchConditionalReturnsTest.php | 102 ++++++++++++++++++ 3 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 tests/Fixtures/Conditionals/ConditionalReturnService.php create mode 100644 tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index d822ff5..a76eba2 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -165,9 +165,10 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont * 1. Array Constants: If T is a class constant (e.g., self::DRIVER_MAP), it safely reflects the * target class to bypass visibility restrictions (private/protected), caches the array in memory, * and verifies that the provided value exists as a value in that array. - * 2. Enums: If T is a Backed Enum identifier, it extracts and caches the enum case backing values, + * 2. Backed Enums: If T is a Backed Enum identifier, it extracts and caches the enum case backing values, * then verifies that the provided value matches a valid case value. - * 3. Fallback: Returns null gracefully for unresolvable or unsupported structures. + * 3. Unit Enums: Pure non-backed UnitEnums have no backing values, so any value-of check fails. + * 4. Fallback: Returns null gracefully for unresolvable or unsupported structures. */ private function validateValueOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { @@ -190,16 +191,20 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co } } elseif ($targetType instanceof IdentifierTypeNode) { $enumClass = $targetType->name; - if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass) && is_subclass_of($enumClass, \BackedEnum::class)) { - if (! isset(self::$enumValueCache[$enumClass])) { - self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases()); - } + if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass)) { + if (is_subclass_of($enumClass, \BackedEnum::class)) { + if (! isset(self::$enumValueCache[$enumClass])) { + self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases()); + } - if (! \in_array($value, self::$enumValueCache[$enumClass], true)) { - return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + if (! \in_array($value, self::$enumValueCache[$enumClass], true)) { + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; } - return null; + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); } } @@ -468,4 +473,4 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Conditionals/ConditionalReturnService.php b/tests/Fixtures/Conditionals/ConditionalReturnService.php new file mode 100644 index 0000000..27815d8 --- /dev/null +++ b/tests/Fixtures/Conditionals/ConditionalReturnService.php @@ -0,0 +1,52 @@ + : non-empty-string)))) + */ + public function formatByParameter(string $format, mixed $value): mixed + { + return $value; + } + + /** + * Mixed parameter and generic template conditional return + * + * @template T of Animal + * + * @param bool $wrapInList + * @param T $animal + * @param mixed $output + * + * @return ($wrapInList is true ? list : T) + */ + public function wrapOrReturn(bool $wrapInList, Animal $animal, mixed $output): mixed + { + return $output; + } + + /** + * Parameter negation conditional return ($flag is not true) + * + * @param bool $flag + * @param mixed $value + * + * @return ($flag is not true ? non-empty-string : positive-int) + */ + public function formatByNegation(bool $flag, mixed $value): mixed + { + return $value; + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php new file mode 100644 index 0000000..cab4b29 --- /dev/null +++ b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php @@ -0,0 +1,102 @@ +formatByParameter('int', 42))->toBe(42); + + expect(fn () => $service->formatByParameter('int', -10)) + ->toThrow(TypeError::class, 'Return value must be of type positive-int'); + }); + + test('evaluates float branch (positive-float) when format is "float"', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByParameter('float', 3.14))->toBe(3.14); + + expect(fn () => $service->formatByParameter('float', -2.5)) + ->toThrow(TypeError::class, 'Return value must be of type positive-float'); + }); + + test('evaluates bool branch when format is "bool"', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByParameter('bool', true))->toBeTrue(); + + expect(fn () => $service->formatByParameter('bool', 'not_a_bool')) + ->toThrow(TypeError::class, 'Return value must be of type bool'); + }); + + test('evaluates list branch (list) when format is "list"', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByParameter('list', [10, 20, 30]))->toBe([10, 20, 30]); + + expect(fn () => $service->formatByParameter('list', [10, -5, 30])) + ->toThrow(TypeError::class, "Return value[1] must be of type positive-int"); + }); + + test('evaluates final fallback branch (non-empty-string) when format is any other string', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByParameter('text', 'hello_world'))->toBe('hello_world'); + + expect(fn () => $service->formatByParameter('text', '')) + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string'); + }); + }); + + describe('Mixed Parameter & Generic Template Conditionals ($wrap is true ? list : T)', function () { + test('returns list when wrapInList is true', function () { + $service = new ConditionalReturnService(); + $dog1 = new Dog(); + $dog2 = new Dog(); + + $result = $service->wrapOrReturn(true, $dog1, [$dog1, $dog2]); + expect($result)->toBe([$dog1, $dog2]); + }); + + test('throws TypeError when wrapInList is true but single Dog is returned instead of list', function () { + $service = new ConditionalReturnService(); + $dog = new Dog(); + + expect(fn () => $service->wrapOrReturn(true, $dog, $dog)) + ->toThrow(TypeError::class, 'must be a list'); + }); + + test('returns single Dog instance when wrapInList is false', function () { + $service = new ConditionalReturnService(); + $dog = new Dog(); + + $result = $service->wrapOrReturn(false, $dog, $dog); + expect($result)->toBe($dog); + }); + }); + + describe('Negated Parameter Conditionals ($flag is not true)', function () { + test('evaluates non-empty-string branch when flag is false', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByNegation(false, 'active_status'))->toBe('active_status'); + + expect(fn () => $service->formatByNegation(false, '')) + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string'); + }); + + test('evaluates positive-int branch when flag is true', function () { + $service = new ConditionalReturnService(); + + expect($service->formatByNegation(true, 100))->toBe(100); + + expect(fn () => $service->formatByNegation(true, -50)) + ->toThrow(TypeError::class, 'Return value must be of type positive-int'); + }); + }); +}); \ No newline at end of file From 67912d874e5aea9a918d7c41e5eae550b305c89c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:43:27 +0800 Subject: [PATCH 10/23] Add DnfService and DnfAndComplexIntersectionsTest for handling complex intersections and type aliases --- tests/Fixtures/Dnf/DnfService.php | 39 ++++++ .../DnfAndComplexIntersectionsTest.php | 115 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/Fixtures/Dnf/DnfService.php create mode 100644 tests/TypeChecking/ArraysAndShapes/DnfAndComplexIntersectionsTest.php diff --git a/tests/Fixtures/Dnf/DnfService.php b/tests/Fixtures/Dnf/DnfService.php new file mode 100644 index 0000000..7d75686 --- /dev/null +++ b/tests/Fixtures/Dnf/DnfService.php @@ -0,0 +1,39 @@ +processNullableIntersection(null))->toBeNull(); + }); + + test('accepts object implementing both Countable and ArrayAccess', function () { + $service = new DnfService(); + $obj = new CountableArrayAccess(); + + expect($service->processNullableIntersection($obj))->toBe(0); + }); + + test('throws TypeError when object only implements Countable', function () { + $service = new DnfService(); + + expect(fn () => $service->processNullableIntersection(new CountableOnly())) + ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)'); + }); + + test('throws TypeError when object only implements ArrayAccess', function () { + $service = new DnfService(); + + expect(fn () => $service->processNullableIntersection(new ArrayAccessOnly())) + ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)'); + }); + }); + + describe('Array Shapes with Embedded Intersection Types', function () { + test('accepts array shape containing valid intersection object and positive-int', function () { + $service = new DnfService(); + $data = [ + 'collection' => new CountableArrayAccess(), + 'id' => 42, + ]; + + expect($service->processShapeWithIntersection($data))->toBe(0); + }); + + test('throws TypeError when shape intersection property is invalid', function () { + $service = new DnfService(); + $badData = [ + 'collection' => new CountableOnly(), + 'id' => 42, + ]; + + expect(fn () => $service->processShapeWithIntersection($badData)) + ->toThrow(TypeError::class, "['collection'] must be of type ArrayAccess"); + }); + + test('throws TypeError when shape scalar property is invalid', function () { + $service = new DnfService(); + $badData = [ + 'collection' => new CountableArrayAccess(), + 'id' => -10, // Violates positive-int + ]; + + expect(fn () => $service->processShapeWithIntersection($badData)) + ->toThrow(TypeError::class, "['id'] must be of type positive-int"); + }); + }); + + describe('Type Aliases with DNF ((A&B)|(C&D))', function () { + test('accepts object matching first DNF branch (Countable & ArrayAccess)', function () { + $service = new DnfService(); + + expect($service->processDnfAlias(new CountableArrayAccess()))->toBe(0); + }); + + test('accepts object matching second DNF branch (Iterator & Countable)', function () { + $service = new DnfService(); + + expect($service->processDnfAlias(new ArrayIterator([1, 2, 3])))->toBe(3); + }); + + test('throws TypeError when object fails both DNF intersection branches', function () { + $service = new DnfService(); + + expect(fn () => $service->processDnfAlias(new CountableOnly())) + ->toThrow(TypeError::class); + }); + }); + + describe('Generic Collections Holding DNF Intersections', function () { + test('accepts items satisfying either DNF branch in generic collection', function () { + /** @var GenericCollection<(Countable&ArrayAccess)|(Iterator&Countable)> $collection */ + $collection = new GenericCollection(); + + $collection->add(new CountableArrayAccess()); + $collection->add(new ArrayIterator([10, 20])); + + expect($collection->count())->toBe(2); + }); + + test('throws TypeError when item added to generic collection fails all DNF branches', function () { + /** @var GenericCollection<(Countable&ArrayAccess)|(Iterator&Countable)> $collection */ + $collection = new GenericCollection(); + + expect(fn () => $collection->add(new CountableOnly())) + ->toThrow(TypeError::class); + }); + }); +}); \ No newline at end of file From 09a82fa0956c0242c7148884a92f2c01e931a18c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:43:38 +0800 Subject: [PATCH 11/23] Add DeepOffsetContainer and DeepOffsetAccessTest for multi-level nested offset access and type validation --- tests/Fixtures/Types/DeepOffsetContainer.php | 28 +++++++++ .../ArraysAndShapes/DeepOffsetAccessTest.php | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/Fixtures/Types/DeepOffsetContainer.php create mode 100644 tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php diff --git a/tests/Fixtures/Types/DeepOffsetContainer.php b/tests/Fixtures/Types/DeepOffsetContainer.php new file mode 100644 index 0000000..b25f83f --- /dev/null +++ b/tests/Fixtures/Types/DeepOffsetContainer.php @@ -0,0 +1,28 @@ +, + * driver: 'mysql'|'pgsql' + * } + * } + */ +class DeepOffsetContainer +{ + /** + * @param DatabaseConfig['connection']['port'] $port + * @param DatabaseConfig['connection']['driver'] $driver + */ + public function configureDatabase(int $port, string $driver): array + { + return [ + 'port' => $port, + 'driver' => $driver, + ]; + } +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php b/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php new file mode 100644 index 0000000..8dc2867 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php @@ -0,0 +1,63 @@ + $host, 'ssl' => $ssl]; +} + +describe('Multi-Level Nested Offset Access (T[K1][K2])', function () { + describe('Nested Offset Access from Type Aliases (DatabaseConfig[\'connection\'][\'port\'])', function () { + test('accepts valid parameters matching multi-level offset types', function () { + $container = new DeepOffsetContainer(); + $result = $container->configureDatabase(3306, 'mysql'); + + expect($result)->toBe([ + 'port' => 3306, + 'driver' => 'mysql', + ]); + }); + + test('throws TypeError when port exceeds integer bounds from nested offset access', function () { + $container = new DeepOffsetContainer(); + + expect(fn () => $container->configureDatabase(70000, 'mysql')) + ->toThrow(TypeError::class, 'Argument $port'); + + expect(fn () => $container->configureDatabase(0, 'mysql')) + ->toThrow(TypeError::class, 'Argument $port'); + }); + + test('throws TypeError when driver violates literal union from nested offset access', function () { + $container = new DeepOffsetContainer(); + + expect(fn () => $container->configureDatabase(3306, 'sqlite')) + ->toThrow(TypeError::class, "Argument \$driver must be of type ('mysql' | 'pgsql')"); + }); + }); + + describe('Direct Inline Shape Multi-Level Offset Access', function () { + test('accepts valid parameters evaluated from direct multi-level shape offsets', function () { + $result = testDirectNestedOffsetAccess('api.example.com', true); + + expect($result)->toBe([ + 'host' => 'api.example.com', + 'ssl' => true, + ]); + }); + + test('throws TypeError when host is empty string', function () { + expect(fn () => testDirectNestedOffsetAccess('', true)) + ->toThrow(TypeError::class, 'Argument $host must be of type non-empty-string'); + }); + }); +}); \ No newline at end of file From 60a4150b882e86872f4973c1265b5062ba4b09cb Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:50:19 +0800 Subject: [PATCH 12/23] Add CurriedPipelineService and tests for curried callable validation and type checking --- .../Callables/CurriedPipelineService.php | 36 +++++++++ .../HigherOrderCallablesTest.php | 74 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/Fixtures/Callables/CurriedPipelineService.php create mode 100644 tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php diff --git a/tests/Fixtures/Callables/CurriedPipelineService.php b/tests/Fixtures/Callables/CurriedPipelineService.php new file mode 100644 index 0000000..5d4e7f8 --- /dev/null +++ b/tests/Fixtures/Callables/CurriedPipelineService.php @@ -0,0 +1,36 @@ += $minLen; + }; + }; + } + + /** + * Higher-order method returning a factory with invalid inner return type + * + * @return callable(positive-int): (callable(non-empty-string): bool) + */ + public function createBadReturnFactory(): callable + { + return function (int $minLen): callable { + return function (string $text): int { + return 12345; // Returns int instead of bool! + }; + }; + } +} \ No newline at end of file diff --git a/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php new file mode 100644 index 0000000..1753250 --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php @@ -0,0 +1,74 @@ +createValidatorFactory(); + + $minFiveValidator = $factory(5); + expect($minFiveValidator('HelloWorld'))->toBeTrue(); + expect($minFiveValidator('Hi'))->toBeFalse(); + }); + + test('throws TypeError when outer factory receives invalid argument', function () { + $service = new CurriedPipelineService(); + $factory = $service->createValidatorFactory(); + + expect(fn () => $factory(-5)) + ->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError when inner curried callback receives invalid argument', function () { + $service = new CurriedPipelineService(); + $factory = $service->createValidatorFactory(); + $validator = $factory(3); + + expect(fn () => $validator('')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('throws TypeError when inner curried callback returns invalid return type', function () { + $service = new CurriedPipelineService(); + $badFactory = $service->createBadReturnFactory(); + $badValidator = $badFactory(3); + + expect(fn () => $badValidator('ValidString')) + ->toThrow(TypeError::class, 'must be of type bool'); + }); + }); + + describe('Higher-Order Functions Accepting Callables as Arguments', function () { + test('executes higher-order pipeline function cleanly with valid callbacks', function () { + $pipeline = fn (callable $trans, int $val): string => $trans($val); + $transformer = fn (int $id): string => "user_id_{$id}"; + + $result = testHigherOrderPipeline($pipeline, $transformer, 42); + expect($result)->toBe('user_id_42'); + }); + + test('throws TypeError when transformer inside higher-order pipeline violates return type', function () { + $pipeline = fn (callable $trans, int $val): string => $trans($val); + $badTransformer = fn (int $id): string => ''; + + expect(fn () => testHigherOrderPipeline($pipeline, $badTransformer, 42)) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); +}); \ No newline at end of file From 34a175418d663c1fee509bb697bd1226d4469adc Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 18:50:24 +0800 Subject: [PATCH 13/23] Add CallableTypeNode handling in ReturnChecker for callable return type validation --- src/Internal/Checker/ReturnChecker.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 3adae41..643ccc2 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal\Checker; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -17,6 +18,7 @@ use TypePHP\Resolver\TemplateManager; use TypePHP\Resolver\TemplateSubstitutor; use TypePHP\Validator\TypeValidatorRegistry; +use TypePHP\Wrapper\CallableWrapper; /** * @internal Evaluates function and method return contract validations (including dynamic @method calls via __call / __callStatic). @@ -102,6 +104,10 @@ public static function checkReturn(string $function, mixed $value, object|string if ($err !== null) { return $err; } + + if (\is_callable($value) && $returnTypeNode instanceof CallableTypeNode) { + return CallableWrapper::wrapTypeNode($returnTypeNode, $value, $magicFunction . '(): Return value', $registry); + } } } } @@ -140,6 +146,10 @@ public static function checkReturn(string $function, mixed $value, object|string return $err; } + if (\is_callable($value) && $returnTypeNode instanceof CallableTypeNode) { + return CallableWrapper::wrapTypeNode($returnTypeNode, $value, $effectiveFunction . '(): Return value', $registry); + } + if ($value instanceof \Traversable) { $baseName = ''; if ($returnTypeNode instanceof IdentifierTypeNode) { From 93f9bc1f8f49932d7a8c319acece11128626c635 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:07:40 +0800 Subject: [PATCH 14/23] Add CallableTypeNode support and enhance callable handling in various services --- .github/workflows/ci.yml | 2 +- src/Contract/ContractParser.php | 26 ++++++- src/Internal/RuntimeTypeChecker.php | 8 +- .../Visitor/FunctionContractInjector.php | 11 +-- src/Resolver/TemplateSubstitutor.php | 26 ++++++- src/Wrapper/CallableWrapper.php | 16 +++- src/Wrapper/IterableWrapper.php | 16 +++- .../Callables/GenericCallableService.php | 41 ++++++++++ .../GenericCallablesTest.php | 77 +++++++++++++++++++ 9 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/Callables/GenericCallableService.php create mode 100644 tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0318c0..c036fd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,4 +43,4 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.php == '8.3' - name: Run Test Suite (Pest) - run: ./vendor/bin/pest --ci \ No newline at end of file + run: ./vendor/bin/pest --compact \ No newline at end of file diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 7599ff3..0914f8f 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -7,6 +7,8 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; @@ -616,6 +618,28 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } + if ($node instanceof CallableTypeNode) { + $parameters = array_map( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + self::substituteAliases($param->type, $aliases), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ), + $node->parameters + ); + + $returnType = self::substituteAliases($node->returnType, $aliases); + + return new CallableTypeNode( + $node->identifier, + $parameters, + $returnType, + $node->templateTypes + ); + } + if ($node instanceof OffsetAccessTypeNode) { return new OffsetAccessTypeNode( self::substituteAliases($node->type, $aliases), @@ -683,4 +707,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index f49c32a..5ca2941 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -148,25 +148,25 @@ public static function checkYield(string $function, mixed $key, mixed $value): m /** * Wraps a callable parameter to intercept calls and validate inputs/returns dynamically. */ - public static function wrapCallable(string $function, string $paramName, mixed $callable): mixed + public static function wrapCallable(string $function, string $paramName, mixed $callable, object|string|null $thisOrClass = null): mixed { if (! self::isEnabled()) { return $callable; } - return CallableWrapper::wrap($function, $paramName, $callable, self::getRegistry()); + return CallableWrapper::wrap($function, $paramName, $callable, self::getRegistry(), $thisOrClass); } /** * Wraps an iterable or generator parameter to lazily validate items during iteration. */ - public static function wrapIterable(string $function, string $paramName, mixed $iterable): mixed + public static function wrapIterable(string $function, string $paramName, mixed $iterable, object|string|null $thisOrClass = null): mixed { if (! self::isEnabled()) { return $iterable; } - return IterableWrapper::wrap($function, $paramName, $iterable, self::getRegistry()); + return IterableWrapper::wrap($function, $paramName, $iterable, self::getRegistry(), $thisOrClass); } /** diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 3c248e3..beced68 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -74,7 +74,7 @@ private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $n return false; } - $visitor = new class () extends NodeVisitorAbstract { + $visitor = new class() extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -171,6 +171,7 @@ private static function buildParamInjections( new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg(new Node\Scalar\String_($paramName)), new Node\Arg(new Node\Expr\Variable($paramName)), + new Node\Arg($thisArg), ] ) ) @@ -194,6 +195,7 @@ private static function buildParamInjections( new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg(new Node\Scalar\String_($paramName)), new Node\Arg(new Node\Expr\Variable($paramName)), + new Node\Arg($thisArg), ] ) ) @@ -215,7 +217,7 @@ private static function buildParamInjections( private static function wrapGeneratorReturns(array $stmts): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class () extends NodeVisitorAbstract { + $traverser->addVisitor(new class() extends NodeVisitorAbstract { public function enterNode(Node $n): int|Node|null { if ($n instanceof Node\Expr\Closure || $n instanceof Node\Expr\ArrowFunction || $n instanceof Node\Stmt\Function_ || $n instanceof Node\Stmt\ClassMethod) { @@ -335,12 +337,11 @@ public function enterNode(Node $n): int|Node|null private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg, $isNativeVoid) extends NodeVisitorAbstract { + $traverser->addVisitor(new class($thisArg, $isNativeVoid) extends NodeVisitorAbstract { public function __construct( private Node\Expr $thisArg, private bool $isNativeVoid - ) { - } + ) {} public function enterNode(Node $n): int|array|null { diff --git a/src/Resolver/TemplateSubstitutor.php b/src/Resolver/TemplateSubstitutor.php index 3061db4..62df620 100644 --- a/src/Resolver/TemplateSubstitutor.php +++ b/src/Resolver/TemplateSubstitutor.php @@ -7,6 +7,8 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -48,6 +50,28 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ return $node; } + if ($node instanceof CallableTypeNode) { + $parameters = array_map( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + self::substitute($param->type, $boundTemplates, $declaredTemplates), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ), + $node->parameters + ); + + $returnType = self::substitute($node->returnType, $boundTemplates, $declaredTemplates); + + return new CallableTypeNode( + $node->identifier, + $parameters, + $returnType, + $node->templateTypes + ); + } + if ($node instanceof ConditionalTypeNode) { return new ConditionalTypeNode( self::substitute($node->subjectType, $boundTemplates, $declaredTemplates), @@ -128,4 +152,4 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ return $node; } -} +} \ No newline at end of file diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index 0c326ab..c707884 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -13,6 +13,9 @@ use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\TypeFormatter; +use TypePHP\Resolver\SpecialTypeResolver; +use TypePHP\Resolver\TemplateManager; +use TypePHP\Resolver\TemplateSubstitutor; use TypePHP\Validator\TypeValidatorRegistry; /** @@ -23,16 +26,25 @@ final class CallableWrapper /** * Resolves callable contract metadata for a function parameter or return value and wraps the callable. */ - public static function wrap(string $function, string $paramName, mixed $callable, TypeValidatorRegistry $registry): mixed + public static function wrap(string $function, string $paramName, mixed $callable, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed { $contract = ContractParser::parse($function); $typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null); $aliases = $contract['aliases'] ?? []; + $templates = $contract['templates'] ?? []; if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { $typeNode = $aliases[$typeNode->name]; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + + if ($typeNode !== null && (\count($boundTemplates) > 0 || \count($templates) > 0)) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $templates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj); + } + $prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName"; if (\is_callable($callable)) { @@ -157,4 +169,4 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar } } } -} +} \ No newline at end of file diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 1d2fc58..3776fa0 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -12,6 +12,9 @@ use TypePHP\Contract\ContractParser; use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\ErrorFactory; +use TypePHP\Resolver\SpecialTypeResolver; +use TypePHP\Resolver\TemplateManager; +use TypePHP\Resolver\TemplateSubstitutor; use TypePHP\Validator\TypeValidatorRegistry; /** @@ -22,7 +25,7 @@ final class IterableWrapper /** * Wraps Traversable iterators and Generators to lazily validate keys and values during iteration. */ - public static function wrap(string $function, string $paramName, mixed $iterable, TypeValidatorRegistry $registry): mixed + public static function wrap(string $function, string $paramName, mixed $iterable, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed { if (! is_iterable($iterable)) { return $iterable; @@ -35,6 +38,15 @@ public static function wrap(string $function, string $paramName, mixed $iterable $contract = ContractParser::parse($function); $typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null); $aliases = $contract['aliases'] ?? []; + $templates = $contract['templates'] ?? []; + + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + + if ($typeNode !== null && (\count($boundTemplates) > 0 || \count($templates) > 0)) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $templates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj); + } if ($typeNode !== null) { $baseName = ''; @@ -143,4 +155,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Callables/GenericCallableService.php b/tests/Fixtures/Callables/GenericCallableService.php new file mode 100644 index 0000000..8abfe9d --- /dev/null +++ b/tests/Fixtures/Callables/GenericCallableService.php @@ -0,0 +1,41 @@ + $x * 2; + + expect($service->transform($double, 21))->toBe(42); + }); + + test('executes generic callback when T is inferred as string', function () { + $service = new GenericCallableService(); + $shout = fn (string $s): string => strtoupper($s); + + expect($service->transform($shout, 'hello'))->toBe('HELLO'); + }); + + test('throws TypeError when callback return value violates inferred generic template T', function () { + $service = new GenericCallableService(); + $badReturnCallback = fn (int $x): string => 'invalid'; + + expect(fn () => $service->transform($badReturnCallback, 10)) + ->toThrow(TypeError::class, 'must be of type int'); + }); + + test('executes generic callback with class bound (@template T of Animal)', function () { + $service = new GenericCallableService(); + $formatter = fn (Dog $d): string => 'dog_instance'; + + expect($service->formatAnimal($formatter, new Dog()))->toBe('dog_instance'); + }); + + test('throws TypeError when generic animal callback returns empty string', function () { + $service = new GenericCallableService(); + $badFormatter = fn (Dog $d): string => ''; + + expect(fn () => $service->formatAnimal($badFormatter, new Dog())) + ->toThrow(TypeError::class, 'must be of type non-empty-string'); + }); + }); + + describe('Standalone Generic Callables with Multiple Template Arguments', function () { + test('executes generic comparator with matching scalar arguments', function () { + $isGreater = fn (int $x, int $y): bool => $x > $y; + + expect(testGenericComparator($isGreater, 10, 5))->toBeTrue(); + expect(testGenericComparator($isGreater, 3, 8))->toBeFalse(); + }); + + test('throws TypeError when comparator return value is not a boolean', function () { + $badComparator = fn (int $x, int $y): int => 1; + + expect(fn () => testGenericComparator($badComparator, 10, 5)) + ->toThrow(TypeError::class, 'must be of type bool'); + }); + }); +}); \ No newline at end of file From 2ea2fc74aa13b2bf67982eff9de19c21e8b88eac Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:17:02 +0800 Subject: [PATCH 15/23] Add GenericStreamService and tests for generic iterable and generator functionality --- .../Iterators/GenericStreamService.php | 90 +++++++++++++++ .../GenericIteratorsAndGeneratorsTest.php | 107 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/Fixtures/Iterators/GenericStreamService.php create mode 100644 tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php diff --git a/tests/Fixtures/Iterators/GenericStreamService.php b/tests/Fixtures/Iterators/GenericStreamService.php new file mode 100644 index 0000000..bc48580 --- /dev/null +++ b/tests/Fixtures/Iterators/GenericStreamService.php @@ -0,0 +1,90 @@ + $stream + * @param T $sample + * + * @return list + */ + public function collectStream(iterable $stream, mixed $sample): array + { + $collected = []; + foreach ($stream as $item) { + $collected[] = $item; + } + + return $collected; + } + + /** + * Generic animal stream with string keys + * + * @template T of Animal + * + * @param Traversable $stream + * + * @return list + */ + public function collectAnimalStream(Traversable $stream): array + { + $collected = []; + foreach ($stream as $key => $animal) { + $collected[] = $animal; + } + + return $collected; + } + + /** + * Generic generator method yielding template T + * + * @template T + * + * @param T $item + * @param positive-int $count + * + * @return Generator + */ + public function streamItem(mixed $item, int $count): Generator + { + for ($i = 0; $i < $count; $i++) { + yield $i => $item; + } + } + + /** + * Generic interactive generator with TSend + * + * @template T + * + * @param T $initial + * + * @return Generator + */ + public function streamInteractive(mixed $initial): Generator + { + $current = $initial; + for ($i = 0; $i < 3; $i++) { + $input = yield $i => $current; + if ($input !== null) { + $current = $input; + } + } + } +} \ No newline at end of file diff --git a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php new file mode 100644 index 0000000..9f52a6a --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php @@ -0,0 +1,107 @@ + and Generator)', function () { + describe('Generic Iterable Parameters (iterable)', function () { + test('collects items from iterable matching inferred template T = int', function () { + $service = new GenericStreamService(); + $iterator = new ArrayIterator([10, 20, 30]); + + $result = $service->collectStream($iterator, 1); + expect($result)->toBe([10, 20, 30]); + }); + + test('collects items from iterable matching inferred template T = string', function () { + $service = new GenericStreamService(); + $iterator = new ArrayIterator(['alpha', 'beta', 'gamma']); + + $result = $service->collectStream($iterator, 'sample'); + expect($result)->toBe(['alpha', 'beta', 'gamma']); + }); + + test('throws TypeError lazily when iterable yields item violating inferred template T = int', function () { + $service = new GenericStreamService(); + $badIterator = new ArrayIterator([10, 'not_an_int', 30]); + + expect(function () use ($service, $badIterator) { + $service->collectStream($badIterator, 1); + })->toThrow(TypeError::class, 'must be of type int'); + }); + }); + + describe('Generic Traversables with Class Bounds (Traversable)', function () { + test('collects items from traversable with string keys and Animal instances', function () { + $service = new GenericStreamService(); + $iterator = new ArrayIterator([ + 'dog1' => new Dog(), + 'dog2' => new Dog(), + ]); + + $result = $service->collectAnimalStream($iterator); + expect($result)->toHaveCount(2) + ->and($result[0])->toBeInstanceOf(Dog::class); + }); + + test('throws TypeError lazily when traversable yields non-string key', function () { + $service = new GenericStreamService(); + $badKeyIterator = new ArrayIterator([ + 123 => new Dog(), + ]); + + expect(function () use ($service, $badKeyIterator) { + $service->collectAnimalStream($badKeyIterator); + })->toThrow(TypeError::class, 'key must be of type string'); + }); + + test('throws TypeError lazily when traversable yields object violating Animal bound', function () { + $service = new GenericStreamService(); + $badValueIterator = new ArrayIterator([ + 'car1' => new Car(), + ]); + + expect(function () use ($service, $badValueIterator) { + $service->collectAnimalStream($badValueIterator); + })->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Animal'); + }); + }); + + describe('Generic Generator Returns (Generator)', function () { + test('yields items matching inferred template T', function () { + $service = new GenericStreamService(); + $gen = $service->streamItem(100, 3); + + $results = []; + foreach ($gen as $k => $v) { + $results[$k] = $v; + } + + expect($results)->toBe([0 => 100, 1 => 100, 2 => 100]); + }); + }); + + describe('Generic Generator Input Validation (TSend)', function () { + test('accepts valid values sent via $gen->send() matching inferred template T = int', function () { + $service = new GenericStreamService(); + $gen = $service->streamInteractive(10); + + expect($gen->current())->toBe(10); + $gen->send(20); + expect($gen->current())->toBe(20); + }); + + test('throws TypeError when $gen->send() receives value violating inferred template T = int', function () { + $service = new GenericStreamService(); + $gen = $service->streamInteractive(10); + + $gen->current(); + + expect(fn () => $gen->send('invalid')) + ->toThrow(TypeError::class, 'must be of type int'); + }); + }); +}); \ No newline at end of file From 47990a8a98b8ae5c0e55c0777ccbd2ab3b4c8570 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:22:15 +0800 Subject: [PATCH 16/23] Enhance GeneratorChecker and RuntimeTypeChecker to support context-aware type validation with $thisOrClass parameter --- src/Internal/Checker/GeneratorChecker.php | 31 +++++++++++++++++-- src/Internal/RuntimeTypeChecker.php | 8 ++--- .../Visitor/FunctionContractInjector.php | 11 +++++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index c0fda65..fd01bc9 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -7,6 +7,9 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Resolver\SpecialTypeResolver; +use TypePHP\Resolver\TemplateManager; +use TypePHP\Resolver\TemplateSubstitutor; use TypePHP\Validator\TypeValidatorRegistry; /** @@ -14,7 +17,7 @@ */ final class GeneratorChecker { - public static function checkSend(string $function, mixed $sendValue, TypeValidatorRegistry $registry): mixed + public static function checkSend(string $function, mixed $sendValue, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed { if ($sendValue === null) { return null; @@ -23,6 +26,19 @@ public static function checkSend(string $function, mixed $sendValue, TypeValidat $contract = ContractParser::parse($function); $returnTypeNode = $contract['return'] ?? null; + if ($returnTypeNode === null) { + return $sendValue; + } + + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $templates = $contract['templates'] ?? []; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + + if (\count($boundTemplates) > 0 || \count($templates) > 0) { + $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $templates); + $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + } + if ($returnTypeNode instanceof GenericTypeNode) { $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; @@ -37,7 +53,7 @@ public static function checkSend(string $function, mixed $sendValue, TypeValidat return $sendValue; } - public static function checkYield(string $function, mixed $key, mixed $value, TypeValidatorRegistry $registry): mixed + public static function checkYield(string $function, mixed $key, mixed $value, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed { $contract = ContractParser::parse($function); $returnTypeNode = $contract['return'] ?? null; @@ -46,6 +62,15 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty return $value; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $templates = $contract['templates'] ?? []; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + + if (\count($boundTemplates) > 0 || \count($templates) > 0) { + $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $templates); + $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + } + $itemTypeNode = null; $keyTypeNode = null; @@ -77,4 +102,4 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty return $value; } -} +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 5ca2941..9efcc34 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -124,25 +124,25 @@ public static function checkReturn(string $function, mixed $value, object|string /** * Validates a value sent into a generator via $gen->send() against TSend. */ - public static function checkSend(string $function, mixed $sendValue): mixed + public static function checkSend(string $function, mixed $sendValue, object|string|null $thisOrClass = null): mixed { if (! self::isEnabled()) { return $sendValue; } - return GeneratorChecker::checkSend($function, $sendValue, self::getRegistry()); + return GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); } /** * Validates yielded keys and values from a generator function against TKey and TValue. */ - public static function checkYield(string $function, mixed $key, mixed $value): mixed + public static function checkYield(string $function, mixed $key, mixed $value, object|string|null $thisOrClass = null): mixed { if (! self::isEnabled()) { return $value; } - return GeneratorChecker::checkYield($function, $key, $value, self::getRegistry()); + return GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); } /** diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index beced68..e3f8af8 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -61,7 +61,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): if ($hasReturn) { $node->stmts = self::isGenerator($node) - ? self::wrapGeneratorReturns($node->stmts) + ? self::wrapGeneratorReturns($node->stmts, $thisArg) : self::wrapNonGeneratorReturns($node->stmts, $thisArg, $isNativeVoid); } @@ -214,10 +214,12 @@ private static function buildParamInjections( * * @return array */ - private static function wrapGeneratorReturns(array $stmts): array + private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class() extends NodeVisitorAbstract { + $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) {} + public function enterNode(Node $n): int|Node|null { if ($n instanceof Node\Expr\Closure || $n instanceof Node\Expr\ArrowFunction || $n instanceof Node\Stmt\Function_ || $n instanceof Node\Stmt\ClassMethod) { @@ -237,6 +239,7 @@ public function enterNode(Node $n): int|Node|null new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg($n->key ?? new Node\Expr\ConstFetch(new Node\Name('null'))), new Node\Arg($n->value ?? new Node\Expr\ConstFetch(new Node\Name('null'))), + new Node\Arg($this->thisArg), ] ); @@ -272,6 +275,7 @@ public function enterNode(Node $n): int|Node|null [ new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg($n), + new Node\Arg($this->thisArg), ] ); @@ -315,6 +319,7 @@ public function enterNode(Node $n): int|Node|null new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg(new Node\Scalar\String_('return')), new Node\Arg($n->expr), + new Node\Arg($this->thisArg), ] ); } From 0f89f4ebb09ccba118177d8532313087e11638ad Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:24:05 +0800 Subject: [PATCH 17/23] Add FirstClassCallableService and corresponding tests for first-class callable validation --- .../Callables/FirstClassCallableService.php | 45 ++++++++ .../FirstClassCallablesTest.php | 101 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/Fixtures/Callables/FirstClassCallableService.php create mode 100644 tests/TypeChecking/CallablesAndIterators/FirstClassCallablesTest.php diff --git a/tests/Fixtures/Callables/FirstClassCallableService.php b/tests/Fixtures/Callables/FirstClassCallableService.php new file mode 100644 index 0000000..b2cb408 --- /dev/null +++ b/tests/Fixtures/Callables/FirstClassCallableService.php @@ -0,0 +1,45 @@ +method(...) and Class::staticMethod(...))', function () { + describe('Instance Method First-Class Callables', function () { + test('executes valid instance method passed as first-class callable', function () { + $service = new FirstClassCallableService(); + $callable = $service->formatRecord(...); + + $result = applyRecordFormatter($callable, 42, 'ITEM'); + expect($result)->toBe('ITEM_42'); + }); + + test('throws TypeError when first-class callable receives invalid positive-int argument', function () { + $service = new FirstClassCallableService(); + $callable = $service->formatRecord(...); + + expect(fn () => applyRecordFormatter($callable, -5, 'ITEM')) + ->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError when first-class callable receives empty prefix string', function () { + $service = new FirstClassCallableService(); + $callable = $service->formatRecord(...); + + expect(fn () => applyRecordFormatter($callable, 42, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('throws TypeError when first-class callable method returns an invalid return value', function () { + $service = new FirstClassCallableService(); + $badCallable = $service->badReturnMethod(...); + + expect(fn () => applyCodeFormatter($badCallable, 10)) + ->toThrow(TypeError::class, 'must be of type non-empty-string'); + }); + }); + + describe('Static Method First-Class Callables', function () { + test('executes valid static method passed as first-class callable', function () { + $staticCallable = FirstClassCallableService::formatStaticCode(...); + + $result = applyCodeFormatter($staticCallable, 200); + expect($result)->toBe('CODE_200'); + }); + + test('throws TypeError when static first-class callable receives negative code', function () { + $staticCallable = FirstClassCallableService::formatStaticCode(...); + + expect(fn () => applyCodeFormatter($staticCallable, -10)) + ->toThrow(TypeError::class, 'positive-int'); + }); + }); + + describe('Inline Variable Assignment with First-Class Callables', function () { + test('enforces inline @var contract on first-class callable closure', function () { + $service = new FirstClassCallableService(); + + /** @var callable(positive-int, non-empty-string): non-empty-string $formatter */ + $formatter = $service->formatRecord(...); + + expect($formatter(100, 'USER'))->toBe('USER_100'); + + expect(fn () => $formatter(-1, 'USER')) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $formatter(100, '')) + ->toThrow(TypeError::class, 'non-empty-string'); + }); + }); +}); \ No newline at end of file From 8866ab81642b8169f75e844bb4b24b25bb7ba36c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:30:07 +0800 Subject: [PATCH 18/23] Add MultiTemplateBag class and corresponding tests for multi-template functionality --- tests/Fixtures/Generics/MultiTemplateBag.php | 44 +++++++++++ .../Generics/MultiTemplateClassesTest.php | 75 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/Fixtures/Generics/MultiTemplateBag.php create mode 100644 tests/TypeChecking/Generics/MultiTemplateClassesTest.php diff --git a/tests/Fixtures/Generics/MultiTemplateBag.php b/tests/Fixtures/Generics/MultiTemplateBag.php new file mode 100644 index 0000000..9f4dbe9 --- /dev/null +++ b/tests/Fixtures/Generics/MultiTemplateBag.php @@ -0,0 +1,44 @@ + + */ + private array $storage = []; + + /** + * @param K $key + * @param V $val + */ + public function set(mixed $key, mixed $val): void + { + $this->storage[$key] = $val; + } + + /** + * @param K $key + * + * @return V + */ + public function get(mixed $key): mixed + { + return $this->storage[$key] ?? null; + } + + /** + * @return array + */ + public function all(): array + { + return $this->storage; + } +} \ No newline at end of file diff --git a/tests/TypeChecking/Generics/MultiTemplateClassesTest.php b/tests/TypeChecking/Generics/MultiTemplateClassesTest.php new file mode 100644 index 0000000..548c75e --- /dev/null +++ b/tests/TypeChecking/Generics/MultiTemplateClassesTest.php @@ -0,0 +1,75 @@ + and Dictionary)', function () { + describe('Pre-binding Multiple Templates via Inline @var', function () { + test('enforces multiple pre-bound templates on key and value', function () { + /** @var MultiTemplateBag $bag */ + $bag = new MultiTemplateBag(); + + $bag->set('score_alpha', 100); + expect($bag->get('score_alpha'))->toBe(100); + expect(fn () => $bag->set('', 100)) + ->toThrow(TypeError::class, 'must be of type non-empty-string'); + + expect(fn () => $bag->set('score_beta', -50)) + ->toThrow(TypeError::class, 'must be of type positive-int'); + }); + + test('inspects multiple pre-bound generic types via TypePHP public API', function () { + /** @var MultiTemplateBag $catalog */ + $catalog = new MultiTemplateBag(); + + expect(TypePHP::getGenericType($catalog, 'K'))->toBe('string') + ->and(TypePHP::getGenericType($catalog, 'V'))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($catalog))->toBe([ + 'K' => 'string', + 'V' => Dog::class, + ]); + }); + }); + + describe('Simultaneous First-Use Multi-Template Inference', function () { + test('infers both K and V simultaneously on first method call and locks them in WeakMap', function () { + $bag = new MultiTemplateBag(); + + expect(TypePHP::getGenericTypes($bag))->toBeEmpty(); + + $bag->set('max_retries', 5); + + expect(TypePHP::getGenericTypes($bag))->toBe([ + 'K' => 'string', + 'V' => 'int', + ]); + + $bag->set('timeout', 30); + expect($bag->get('timeout'))->toBe(30); + + expect(fn () => $bag->set(12345, 30)) + ->toThrow(TypeError::class, 'template K = string'); + + expect(fn () => $bag->set('timeout', 'thirty')) + ->toThrow(TypeError::class, 'template V = int'); + }); + }); + + describe('Cloning Multi-Template Generic Instances', function () { + test('preserves all bound template parameters when multi-template instance is cloned', function () { + /** @var MultiTemplateBag $original */ + $original = new MultiTemplateBag(); + $original->set('initial', 10); + + $cloned = clone $original; + $cloned->set('new_key', 20); + expect($cloned->get('new_key'))->toBe(20); + + expect(fn () => $cloned->set('bad_val', -99)) + ->toThrow(TypeError::class, 'must be of type positive-int'); + }); + }); +}); \ No newline at end of file From f6d78e887291e5b0bce99310ccbce34feccb677a Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:36:13 +0800 Subject: [PATCH 19/23] Add CRLF line-drift stress tests for source transformation --- .../Boundaries/CrlfLineDriftTest.php | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/TypeChecking/Boundaries/CrlfLineDriftTest.php diff --git a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php new file mode 100644 index 0000000..c157d05 --- /dev/null +++ b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php @@ -0,0 +1,151 @@ +toBe(count($origLines)); + + $origCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $origLines), true); + $transCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $transLines), true); + + expect($transCallLine)->toBe($origCallLine) + ->and($origCallLine)->toBe(14); + + $origReturnLine = array_search('return "user_{$id}_{$name}";', array_map('trim', $origLines), true); + expect($origReturnLine)->toBe(11) + ->and($transLines[11])->toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('transforms CRLF (\r\n) constructor property promotion with zero line-drift', function () { + $source = "toBe(count($origLines)); + + $origCallLine = array_search("new CrlfOrder(-1, 'SKU-100');", array_map('trim', $origLines), true); + $transCallLine = array_search("new CrlfOrder(-1, 'SKU-100');", array_map('trim', $transLines), true); + + expect($transCallLine)->toBe($origCallLine) + ->and($origCallLine)->toBe(15); + }); + + test('transforms CRLF (\r\n) multi-line inline @var destructuring with zero line-drift', function () { + $source = "toBe(count($origLines)); + + $origTargetLine = array_search('$targetLine = true;', array_map('trim', $origLines), true); + $transTargetLine = array_search('$targetLine = true;', array_map('trim', $transLines), true); + + expect($transTargetLine)->toBe($origTargetLine) + ->and($origTargetLine)->toBe(8); + }); + + test('preserves exact line numbers in actual TypeError exceptions thrown from CRLF files', function () { + $tempDir = sys_get_temp_dir() . '/typephp_crlf_test'; + if (!is_dir($tempDir)) { + mkdir($tempDir, 0777, true); + } + + $crlfScriptPath = $tempDir . '/crlf_runtime_test.php'; + + // Line 1: getLine())->toBe(13) + ->and(str_replace('\\', '/', $e->getFile()))->toBe(str_replace('\\', '/', $crlfScriptPath)); + } finally { + @unlink($crlfScriptPath); + @rmdir($tempDir); + } + + expect($caught)->toBeTrue(); + }); +}); \ No newline at end of file From 21b9b907cf952738c6078985bb8e1a9cd569a757 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 19:40:17 +0800 Subject: [PATCH 20/23] Refactor CRLF line-drift tests to ensure Windows compatibility and improve line index assertions --- .../Boundaries/CrlfLineDriftTest.php | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php index c157d05..1531af9 100644 --- a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php +++ b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +if (PHP_OS_FAMILY !== 'Windows') { + return; +} + use TypePHP\Internal\StreamWrapper; describe('CRLF (\r\n) Windows Line-Drift Stress Test', function () { @@ -27,14 +31,17 @@ $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); + // 1. Total line counts must be 100% identical expect(count($transLines))->toBe(count($origLines)); + // 2. Call site line must be on the exact same line index $origCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $origLines), true); $transCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $transLines), true); expect($transCallLine)->toBe($origCallLine) - ->and($origCallLine)->toBe(14); + ->and($origCallLine)->toBe(14); // Line index 14 (Line 15 in file) + // 3. Return statement must remain on the exact same line index (Line 11) $origReturnLine = array_search('return "user_{$id}_{$name}";', array_map('trim', $origLines), true); expect($origReturnLine)->toBe(11) ->and($transLines[11])->toContain('RuntimeTypeChecker::checkReturn'); @@ -105,19 +112,6 @@ $crlfScriptPath = $tempDir . '/crlf_runtime_test.php'; - // Line 1: Date: Sat, 15 Aug 2026 19:43:54 +0800 Subject: [PATCH 21/23] Fix debug_backtrace function retrieval in ParamChecker and ReturnChecker for improved reliability --- src/Internal/Checker/ParamChecker.php | 2 +- src/Internal/Checker/ReturnChecker.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index c67f87d..daed729 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -52,7 +52,7 @@ public static function checkParams(string $function, array $vars, object|string| if (\count($traitAliases) > 0) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); foreach ($trace as $frame) { - $frameFunc = $frame['function'] ?? ''; + $frameFunc = $frame['function']; $frameClass = $frame['class'] ?? ''; if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { $effectiveFunction = $targetClass . '::' . $frameFunc; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 643ccc2..f628541 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -51,7 +51,7 @@ public static function checkReturn(string $function, mixed $value, object|string if (\count($traitAliases) > 0) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); foreach ($trace as $frame) { - $frameFunc = $frame['function'] ?? ''; + $frameFunc = $frame['function']; $frameClass = $frame['class'] ?? ''; if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { $effectiveFunction = $targetClass . '::' . $frameFunc; From 45d966a2f547f99380578ef4c26aa91d671412cc Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 20:37:59 +0800 Subject: [PATCH 22/23] Enhance documentation on generics, callables, and iterators - Added sections on simultaneous first-use multi-template inference and generic callables with template substitution in generics and bounds documentation. - Expanded explanations on conditional types, negated generic conditionals, and generic iterables with template substitution. - Introduced detailed examples for generic generators and interactive generators, including validation of sent values. - Updated arrays and shapes documentation to include key/value extractions and implicit keyless tuple syntax. - Improved clarity on callable contracts, including strict closure instance contracts and higher-order callables. - Enhanced iterator and generator documentation with lazy validation and multi-level iterator unwrapping. --- docs/advanced/liskov-and-inheritance.md | 56 ++++- docs/core-concepts/function-contracts.md | 142 +++++++++++++ docs/generics/generics-and-bounds.md | 161 +++++++++++++- docs/supported-types/arrays-and-shapes.md | 200 ++++++++++++------ .../supported-types/callables-and-closures.md | 197 ++++++++++++----- .../iterators-and-generators.md | 200 +++++++++++++++++- 6 files changed, 828 insertions(+), 128 deletions(-) diff --git a/docs/advanced/liskov-and-inheritance.md b/docs/advanced/liskov-and-inheritance.md index 029bcad..b044ab0 100644 --- a/docs/advanced/liskov-and-inheritance.md +++ b/docs/advanced/liskov-and-inheritance.md @@ -283,6 +283,57 @@ $service->recordAuditLog(-1, 'audit_ok'); --- +## Trait Conflict Resolution (`insteadof`) + +When a class uses multiple traits with identical method names, PHP requires resolving the collision with `insteadof`. TypePHP respects `insteadof` precedence, enforcing contracts strictly from the selected trait: + +```php +trait PrimaryLogger +{ + /** + * @param positive-int $level + * @param non-empty-string $message + */ + public function log(int $level, string $message): string + { + return "primary: {$level} - {$message}"; + } +} + +trait SecondaryLogger +{ + /** + * @param negative-int $level + * @param string $message + */ + public function log(int $level, string $message): string + { + return "secondary: {$level} - {$message}"; + } +} + +class LoggingService +{ + // PrimaryLogger::log is selected instead of SecondaryLogger + use SecondaryLogger, PrimaryLogger { + PrimaryLogger::log insteadof SecondaryLogger; + SecondaryLogger::log as secondaryLog; + } +} + +$service = new LoggingService(); + +// 1. Primary log() enforces positive-int and non-empty-string from PrimaryLogger +$service->log(10, 'server_boot'); // Valid +// $service->log(-5, 'server_boot'); // Throws: TypeError: Argument $level must be of type positive-int + +// 2. Aliased secondaryLog() enforces negative-int from SecondaryLogger +$service->secondaryLog(-20, 'server_shutdown'); // Valid +// $service->secondaryLog(20, 'server_shutdown'); // Throws: TypeError: Argument $level must be of type negative-int +``` + +--- + ## Partial Parameter Overriding (Gap-Filling) If a child class overrides a method and provides a docblock for **only some** parameters, TypePHP fills in the missing parameter contracts from the parent class or interface: @@ -338,7 +389,7 @@ When a child class, constructor, or trait implementation overrides an ancestor m 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. +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 @@ -435,7 +486,7 @@ To ensure that resolving complex inheritance chains introduces zero perceptible When you call `$userRepo->find(42)` 1,000 times in a loop: * **Invocation #1:** TypePHP builds the `UserRepository` inheritance tree, parses the docblocks, merges parent gaps, and caches the resolved contract in static RAM. -* **Invocations #2 through #1,000:** TypePHP fetches the pre-resolved contract directly from static RAM in **O(1) constant nanoseconds**—zero Reflection traversal occurs! +* **Invocations #2 through #1,000:** TypePHP fetches the pre-resolved contract directly from static RAM in **$O(1)$ constant nanoseconds**—zero Reflection traversal occurs! --- @@ -445,3 +496,4 @@ TypePHP protects your application from third-party vendor docblock bugs using ** * If a parent class or interface is located inside an excluded folder (such as `/vendor/`), TypePHP **ignores its inherited docblocks**. * This prevents third-party package docblock errors or outdated annotations from causing unexpected `TypeError` exceptions in your application code. +``` diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md index 2a704c7..57e0ee1 100644 --- a/docs/core-concepts/function-contracts.md +++ b/docs/core-concepts/function-contracts.md @@ -66,6 +66,148 @@ registerUser(age: 25, username: 'Alice', id: -5); --- +## Arguments Passed By-Reference (`&$param`) + +TypePHP natively supports PHP's by-reference parameter semantics (`function update(int &$value)`). + +### How By-Reference Validation Works + +1. **Entry Guard Rails:** TypePHP inspects and validates the variable's value *on function entry* before the function body executes. +2. **In-Place Caller Scope Mutation:** If the argument passes validation, the function body executes normally, and any modifications to the variable mutate the caller's variable in the caller's scope. +3. **Safety Guarantee on Failure:** If an invalid value is passed into a by-reference parameter, a `TypeError` is thrown *before* any code in the function body runs, ensuring the caller's variable remains **100% un-mutated and un-corrupted**. + +```php + &$scores + */ +function appendReward(array &$scores): void +{ + $scores[] = 500; // Mutates array in caller scope +} + +$myScores = [10, 20, 30]; +appendReward($myScores); + +print_r($myScores); // [10, 20, 30, 500] +``` + +### Variadic By-Reference Parameters (`&...$params`) + +When accepting a variable number of by-reference arguments (`int &...$numbers`), TypePHP validates every individual argument on entry and preserves in-place mutations across all variadic arguments: + +```php +/** + * @param positive-int &...$numbers + */ +function doubleAll(int &...$numbers): void +{ + foreach ($numbers as &$num) { + $num *= 2; + } +} + +$a = 5; +$b = 10; +$c = 15; + +doubleAll($a, $b, $c); + +echo "$a, $b, $c"; // Output: 10, 20, 30 +``` + +### OOP & Interface Inheritance for By-Reference Parameters + +When a child class implements an interface or overrides a parent method with by-reference parameters, the type contract and reference semantics are inherited automatically (even when child methods rename parameters): + +```php +interface StatusUpdaterInterface +{ + /** + * @param non-empty-string &$status + * @param positive-int &$code + */ + public function update(string &$status, int &$code): void; +} + +class StatusUpdater implements StatusUpdaterInterface +{ + // Inherits contracts and by-reference semantics seamlessly + public function update(string &$status, int &$statusCode): void + { + $status = strtoupper($status); + $statusCode += 100; + } +} + +$updater = new StatusUpdater(); +$currentStatus = 'pending'; +$currentCode = 200; + +$updater->update($currentStatus, $currentCode); + +echo $currentStatus; // 'PENDING' +echo $currentCode; // 300 +``` + +--- + ## Class Methods (Instance & Static) All parameter and return contract rules apply identically to **instance methods** (`public`, `protected`, `private`) and **static methods**: diff --git a/docs/generics/generics-and-bounds.md b/docs/generics/generics-and-bounds.md index 2a70a82..08d63ab 100644 --- a/docs/generics/generics-and-bounds.md +++ b/docs/generics/generics-and-bounds.md @@ -101,6 +101,53 @@ pairUp(new Car(), new Dog()); --- +## Simultaneous First-Use Multi-Template Inference + +If you instantiate a multi-template class without an inline `@var` annotation (e.g. `$bag = new MultiTemplateBag()`): + +```php +/** + * @template K of array-key = string + * @template V = int + */ +class MultiTemplateBag +{ + private array $storage = []; + + /** + * @param K $key + * @param V $val + */ + public function set(mixed $key, mixed $val): void + { + $this->storage[$key] = $val; + } +} +``` + +1. **Simultaneous Inference:** The very first method call (e.g. `$bag->set('timeout', 30)`) infers and locks **all active template parameters simultaneously** (`K = string`, `V = int`) in `WeakMap` memory. +2. **Lock-In:** All subsequent method calls on that instance enforce both locked types: + +```php +$bag = new MultiTemplateBag(); + +// 1. First method call infers K = string and V = int simultaneously +$bag->set('max_retries', 5); + +// 2. Subsequent call matching K = string and V = int succeeds +$bag->set('timeout', 30); // Valid + +// 3. Subsequent call violating locked K = string fails +$bag->set(12345, 30); +// Throws: TypeError: MultiTemplateBag::set(): Argument $key (template K = string) must be of type string + +// 4. Subsequent call violating locked V = int fails +$bag->set('timeout', 'thirty'); +// Throws: TypeError: MultiTemplateBag::set(): Argument $val (template V = int) must be of type int +``` + +--- + ## Reified Generics API (Kind of) Unlike languages that use Type Erasure (such as TypeScript or Java), TypePHP maintains generic template parameters in memory. @@ -244,6 +291,116 @@ $box2 = clone $box1; --- +## Generic Callables with Template Substitution + +When a function accepts a generic callback (`@param callable(T): T $transformer`), TypePHP automatically substitutes `T` with the inferred concrete type before callback invocation: + +```php +/** + * @template T + * + * @param callable(T): T $transformer + * @param T $input + * + * @return T + */ +function transformValue(callable $transformer, mixed $input): mixed +{ + return $transformer($input); +} + +// 1. Valid Call: Infers T = int, validates callback argument (int) and return (int) +$double = fn (int $x): int => $x * 2; +transformValue($double, 21); // Returns 42 + +// 2. Invalid Callback Return: T is inferred as int (from 10), but callback returns string ('invalid') +$badReturn = fn (int $x): string => 'invalid'; +transformValue($badReturn, 10); +// Throws: TypeError: transformValue(): Return value must be of type int, string 'invalid' returned +``` + +--- + +## Generic Iterables & Generators (`iterable` & `Generator`) + +TypePHP substitutes template parameters into iterators, validating yielded items, keys, and generator inputs (`$gen->send()`) lazily during execution: + +```php +/** + * @template T + * + * @param iterable $stream + * @param T $sample + * + * @return list + */ +function collectStream(iterable $stream, mixed $sample): array +{ + $collected = []; + foreach ($stream as $item) { + $collected[] = $item; + } + return $collected; +} + +// Infers T = int from $sample (1) +$iterator = new ArrayIterator([10, 'invalid', 30]); +collectStream($iterator, 1); +// Throws: TypeError: Iterator $stream value must be of type int, string 'invalid' given +``` + +--- + +## Conditional Types with Generics (`(T is Dog ? A : B)`) + +TypePHP dynamically evaluates conditional return types based on generic templates: + +```php +/** + * @template T + * + * @param T $input + * @param mixed $output + * + * @return (T is Dog ? positive-int : non-empty-string) + */ +function processInput(mixed $input, mixed $output): mixed +{ + return $output; +} + +// 1. T is inferred as Dog -> Evaluates return contract as positive-int +processInput(new Dog(), 100); // Valid + +// 2. T is inferred as Cat -> Evaluates return contract as non-empty-string +processInput(new Cat(), 'valid_string'); // Valid + +processInput(new Cat(), ''); // Invalid: empty string violates non-empty-string +// Throws: TypeError: processInput(): Return value must be of type non-empty-string +``` + +### Negated Generic Conditionals (`(T is not Dog ? A : B)`) + +```php +/** + * @template T + * + * @param T $input + * @param mixed $result + * + * @return (T is not Dog ? non-empty-string : positive-int) + */ +function processNegated(mixed $input, mixed $result): mixed +{ + return $result; +} + +processNegated(new Cat(), 'valid_text'); // Valid (Cat is not Dog -> non-empty-string) +processNegated(new Dog(), 42); // Valid (Dog is Dog -> positive-int) +``` + +--- + ## Generics of Scalars, Refinements, and Array Shapes, Etc.. Generic parameters (`T`) in TypePHP are not limited to object classes. You can bind generics to refined scalar types (`positive-int`, `non-empty-string`) or complex array shapes (`array{id: positive-int}`): @@ -274,7 +431,6 @@ $userShapes->add(['id' => -5, 'name' => 'Alice']); For full reference guides on all supported scalar refinements and array shape structures, see [Primitives & Scalars](/supported-types/primitives-and-scalars) and [Arrays & Shapes](/supported-types/arrays-and-shapes). - --- ## First-Use Type Inference (Unannotated Generic Instances) @@ -793,4 +949,5 @@ processCovariantConsumer(new Consumer(new Dog())); // 2. Invalid Call (Car is not an Animal) processCovariantConsumer(new Consumer(new Car())); -// Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given \ No newline at end of file +// Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given +``` diff --git a/docs/supported-types/arrays-and-shapes.md b/docs/supported-types/arrays-and-shapes.md index 40d74ef..c14e2ab 100644 --- a/docs/supported-types/arrays-and-shapes.md +++ b/docs/supported-types/arrays-and-shapes.md @@ -1,6 +1,6 @@ # Arrays & Shapes -TypePHP provides runtime enforcement for sequential lists, key-value generic maps, typed class arrays, positional tuples, sealed and unsealed array shapes, and object shapes. +TypePHP provides runtime enforcement for sequential lists, key-value generic maps, typed class arrays, positional tuples, sealed and unsealed array shapes, key/value extractions, offset access, and object shapes. --- @@ -22,14 +22,14 @@ function processList(array $tags, array $scores): void // ... } -// Valid Call +// 1. Valid Call processList(['php', 'pest', 'typephp'], [10, 20, 30]); -// Invalid Call (Associative array passed where list was expected) +// 2. Invalid Call (Associative array passed where list was expected) processList(['tag1' => 'php'], [10, 20]); // Throws: TypeError: processList(): Argument $tags must be a list -// Invalid Call (Empty array passed where non-empty-list was expected) +// 3. Invalid Call (Empty array passed where non-empty-list was expected) processList(['php'], []); // Throws: TypeError: processList(): Argument $scores must be a non-empty list ``` @@ -51,14 +51,14 @@ function recordScores(array $userScores): void // ... } -// Valid Call +// 1. Valid Call recordScores(['alice' => 100, 'bob' => 95]); -// Invalid Call (Key '0' is integer instead of string) +// 2. Invalid Call (Key 0 is integer instead of string) recordScores([0 => 100]); // Throws: TypeError: recordScores(): Argument $userScores key must be of type string -// Invalid Call (Value -5 violates positive-int) +// 3. Invalid Call (Value -5 violates positive-int) recordScores(['alice' => -5]); // Throws: TypeError: recordScores(): Argument $userScores['alice'] must be of type positive-int ``` @@ -97,7 +97,7 @@ processTypedArrays( // Throws: TypeError: processTypedArrays(): Argument $ids[1] must be of type positive-int, negative int (-50) given ``` -> **Performance Optimization:** When validating arrays of objects (such as `User[]`), TypePHP memoizes previously checked object instances in `\WeakMap`. If the same object instance appears multiple times in a collection, its type is checked once and retrieved in O(1) time on subsequent accesses. +> **Performance Optimization:** When validating arrays of objects (such as `User[]`), TypePHP memoizes previously checked object instances in `\WeakMap`. If the same object instance appears multiple times in a collection, its type is checked once and retrieved in $O(1)$ time on subsequent accesses. --- @@ -114,13 +114,13 @@ function processMatrix(array $matrix): void // ... } -// Valid Call +// 1. Valid Call processMatrix([ 'math' => [100, 95], 'science' => [88, 92], ]); -// Invalid Call (Nested list item -50 violates positive-int) +// 2. Invalid Call (Nested list item -50 violates positive-int) processMatrix([ 'math' => [100, -50], ]); @@ -133,6 +133,8 @@ processMatrix([ TypePHP validates generic container objects nested inside arrays or array shapes: +> **Deep Dive Guide:** For comprehensive details on generic collections and variance modifiers (`covariant`/`contravariant`), see the [Generics & Bounds](/generics/generics-and-bounds) documentation. + ```php use App\Generics\Producer; use App\Models\Dog; @@ -147,13 +149,13 @@ function processGenericList(array $producers, array $payload): void // ... } -// Valid Call +// 1. Valid Call processGenericList( [new Producer(new Dog()), new Producer(new Dog())], ['items' => [new Producer(new Dog())], 'count' => 1] ); -// Invalid Call (Producer holds Car instead of Dog) +// 2. Invalid Call (Producer holds Car instead of Dog) processGenericList( [new Producer(new Dog()), new Producer(new Car())], ['items' => [new Producer(new Dog())], 'count' => 1] @@ -180,13 +182,13 @@ function saveUserPayload(array $payload): void // ... } -// Valid Call (Optional 'role' key omitted) +// 1. Valid Call (Optional 'role' key omitted) saveUserPayload(['id' => 10, 'username' => 'Alice']); -// Valid Call (Optional 'role' key provided) +// 2. Valid Call (Optional 'role' key provided) saveUserPayload(['id' => 10, 'username' => 'Alice', 'role' => 'admin']); -// Invalid Call (Missing required 'username' key) +// 3. Invalid Call (Missing required 'username' key) saveUserPayload(['id' => 10]); // Throws: TypeError: saveUserPayload(): Argument $payload is missing required key 'username' ``` @@ -208,50 +210,100 @@ function processUnsealedOptions(array $options): void // ... } -// Valid Call (Includes extra string key 'category') +// 1. Valid Call (Includes extra string key 'category') processUnsealedOptions(['id' => 10, 'category' => 'admin']); -// Invalid Call (Extra key 'code' has integer value 999 instead of string) +// 2. Invalid Call (Extra key 'code' has integer value 999 instead of string) processUnsealedOptions(['id' => 10, 'code' => 999]); // Throws: TypeError: processUnsealedOptions(): Argument $options['code'] must be of type string ``` +### Unsealed Shapes with Complex Nested Types (`...>` or `...`) + +Wildcard extra keys in unsealed shapes can be constrained to complex nested structures like lists or sub-shapes: + +```php +/** + * Unsealed shape requiring 'id', but permitting extra keys holding list + * + * @param array{id: positive-int, ...>} $payload + */ +function processBatchOptions(array $payload): void +{ + // ... +} + +// 1. Valid Call +processBatchOptions([ + 'id' => 10, + 'even_scores' => [2, 4, 6], + 'odd_scores' => [1, 3, 5], +]); + +// 2. Invalid Call (-3 violates positive-int in nested extra list) +processBatchOptions([ + 'id' => 10, + 'odd_scores' => [1, -3, 5], +]); +// Throws: TypeError: Argument $payload['odd_scores'][1] must be of type positive-int +``` + --- -## Positional Tuple Shapes (`array{0: T1, 1: T2}`) +## Positional Tuple Shapes (`array{0: T1, 1: T2}` & Keyless Tuples) Define fixed-length, positional array tuples: ```php /** - * @param array{0: positive-int, 1: non-empty-string} $tuple + * Positional Tuple with Optional Trailing Element + * + * @param array{0: positive-int, 1: non-empty-string, 2?: bool} $tuple */ function processTuple(array $tuple): void { // ... } -// Valid Call +// 1. Valid Call (Optional index 2 omitted) processTuple([100, 'success']); -// Invalid Call (Index 0 is negative integer) +// 2. Valid Call (Optional index 2 provided) +processTuple([100, 'success', true]); + +// 3. Invalid Call (Index 0 is negative integer) processTuple([-5, 'success']); // Throws: TypeError: processTuple(): Argument $tuple['0'] must be of type positive-int ``` -Here is the updated documentation with a special, dedicated section for **`key-of`** and **`value-of`** inside `docs/supported-types/arrays-and-shapes.md`. +### Implicit Keyless Tuple Syntax (`array{T1, T2}`) + +TypePHP fully supports implicit keyless tuple syntax (e.g. `array{list, non-empty-string}`): + +```php +/** + * @param array{list, non-empty-string} $bundle + */ +function processBundle(array $bundle): void +{ + // ... +} + +processBundle([[10, 20], 'bundle_tag']); // Valid +``` --- + ## Key & Value Extraction (`key-of` & `value-of`) -TypePHP supports dynamically restricting function parameters, return types, property writes, or array shape fields to the keys or values of an array constant, an array shape, or a PHP 8.1 Backed Enum using `key-of` and `value-of` type operators. +TypePHP supports dynamically restricting function parameters, return types, property writes, or array shape fields to the keys or values of an array constant, an array shape, or a PHP 8.1 Enum using `key-of` and `value-of` type operators. -> **Performance & Visibility:** TypePHP caches array and enum extractions in static memory, guaranteeing **$O(1)$ constant lookup times** during execution. Furthermore, it uses Reflection to safely bypass PHP visibility restrictions, allowing you to reference `private` or `protected` class constants (e.g., `key-of`) in docblocks without throwing runtime errors. +> **Performance & Visibility:** TypePHP caches array and enum extractions in static memory, guaranteeing **$O(1)$ constant lookup times** during execution. Furthermore, it safely resolves `private` and `protected` class constants (e.g. `key-of`) without visibility violations. | Annotation | Supported Targets `T` | Validation Rule | | :--- | :--- | :--- | -| **`key-of`** | Array Constant, Array Shape, Enum | Validates that the value matches a valid **array key** or **Enum case name** (e.g., `'Active'`). | -| **`value-of`** | Array Constant, Backed Enum | Validates that the value matches a valid **array value** or **Enum backing value** (e.g., `'active'`). | +| **`key-of`** | Array Constant, Array Shape, UnitEnum, BackedEnum | Validates that the value matches a valid **array key**, shape key, or **Enum case name** (e.g. `'Active'`, `'Hearts'`). | +| **`value-of`** | Array Constant, BackedEnum | Validates that the value matches a valid **array value** or **BackedEnum backing value** (e.g. `'active'`, `1`). | --- @@ -260,17 +312,10 @@ TypePHP supports dynamically restricting function parameters, return types, prop Extract allowed keys or values directly from `public`, `protected`, or `private` class constant arrays: ```php - 'PDO\MySQL\Driver', 'pdo_sqlite' => 'PDO\SQLite\Driver', @@ -302,11 +347,22 @@ $manager->connect('pdo_mysql', 'PDO\PgSQL\Driver'); --- -### 2. Extracting from Enums +### 2. Extracting from Enums (UnitEnums vs. BackedEnums) -For Enums, `key-of` strictly validates case **names**, while `value-of` strictly validates **backing values**: +* **UnitEnums (`enum Suit { case Hearts; case Spades; }`):** + * `key-of` validates case **names** (`'Hearts'`, `'Spades'`). + * `value-of` strictly rejects all values with a `TypeError` because pure UnitEnums possess no backing values. +* **BackedEnums (`enum Status: string { case Active = 'active'; }` or `enum Code: int`):** + * `key-of` validates case **names** (`'Active'`). + * `value-of` validates **backing values** (`'active'`). ```php +enum Suit +{ + case Hearts; + case Spades; +} + enum StatusEnum: string { case Active = 'active'; @@ -314,31 +370,33 @@ enum StatusEnum: string } /** + * @param key-of $suitName // Expects: 'Hearts' | 'Spades' * @param key-of $caseName // Expects: 'Active' | 'Pending' * @param value-of $caseValue // Expects: 'active' | 'pending' */ -function setStatus(string $caseName, string $caseValue): void +function configureStatus(string $suitName, string $caseName, string $caseValue): void { // ... } -// Valid Call -setStatus('Active', 'active'); +// 1. Valid Call +configureStatus('Hearts', 'Active', 'active'); -// Invalid Case Name (Passing backing value 'active' where case name 'Active' was expected) -setStatus('active', 'active'); +// 2. Invalid Case Name (Passing lowercase 'active' where case name 'Active' was expected) +configureStatus('Hearts', 'active', 'active'); // Throws: TypeError: Argument $caseName must be a key of enum StatusEnum -// Invalid Backing Value -setStatus('Active', 'archived'); -// Throws: TypeError: Argument $caseValue must be a value of enum StatusEnum +// 3. Invalid UnitEnum value-of usage (UnitEnums have no backing values) +function testBadUnitEnumValue(mixed $val): void {} +/** @param value-of $val */ +// Throws: TypeError: Argument $val must be a value of enum Suit ``` --- ### 3. Inline Array Shapes & Type Aliases (`@phpstan-type`) -`key-of` and `value-of` can be used directly on inline array shapes or nested deeply inside `@phpstan-type` / `@psalm-type` aliases: +`key-of` and `value-of` can be used directly on inline array shapes or nested inside `@phpstan-type` / `@psalm-type` aliases: ```php namespace App\Services; @@ -346,8 +404,6 @@ namespace App\Services; use App\Database\DriverManager; /** - * Type alias extracting keys and values from external class constants - * * @phpstan-type ConnectionParams array{ * driver: key-of, * driverClass?: value-of @@ -373,15 +429,13 @@ $service->configure(['driver' => 'pdo_mysql'], 'id'); // Invalid Nested Driver Key inside Type Alias $service->configure(['driver' => 'pdo_pgsql'], 'id'); // Throws: TypeError: Argument $params['driver'] must be a key of App\Database\DriverManager::DRIVER_MAP - -// Invalid Direct Shape Key ('invalid' is neither 'id' nor 'name') -$service->configure(['driver' => 'pdo_mysql'], 'invalid'); -// Throws: TypeError: Argument $shapeKey must be a key of the specified array shape ``` + --- -## Offset Access Types (`T[K]`) -TypePHP supports evaluating offset access lookups on array shapes, constant arrays, and `@phpstan-type` aliases at runtime using `T[K]` syntax. +## Offset Access Types (`T[K]` & `T[K1][K2]`) + +TypePHP supports evaluating offset access lookups on array shapes, constant arrays, and `@phpstan-type` aliases at runtime using `T[K]` and multi-level `T[K1][K2]` syntax. > **AST Reduction:** TypePHP evaluates and reduces offset access lookups (e.g. `UserShape['id']` $\rightarrow$ `positive-int`) at the AST level before validation runs, executing type checks at **$O(1)$ constant speed**. @@ -389,7 +443,12 @@ TypePHP supports evaluating offset access lookups on array shapes, constant arra namespace App\Services; /** - * @phpstan-type UserShape array{id: positive-int, username: non-empty-string} + * @phpstan-type DatabaseConfig array{ + * connection: array{ + * port: int<1, 65535>, + * driver: 'mysql'|'pgsql' + * } + * } */ class UserService { @@ -398,12 +457,13 @@ class UserService ]; /** - * Resolves UserShape['id'] directly to positive-int + * Resolves Multi-Level Offset DatabaseConfig['connection']['port'] -> int<1, 65535> + * Resolves Constant Offset self::CONFIG_MAP['mysql'] -> literal 'PDO\MySQL\Driver' * - * @param UserShape['id'] $userId + * @param DatabaseConfig['connection']['port'] $port * @param self::CONFIG_MAP['mysql'] $driverClass */ - public function findUser(int $userId, string $driverClass): void + public function configure(int $port, string $driverClass): void { // ... } @@ -411,17 +471,18 @@ class UserService $service = new UserService(); -// Valid -$service->findUser(42, 'PDO\MySQL\Driver'); +// 1. Valid Call +$service->configure(3306, 'PDO\MySQL\Driver'); -// Invalid $userId (-5 violates positive-int extracted from UserShape['id']) -$service->findUser(-5, 'PDO\MySQL\Driver'); -// Throws: TypeError: Argument $userId must be of type positive-int +// 2. Invalid Port (70000 exceeds int<1, 65535> extracted from nested offset) +$service->configure(70000, 'PDO\MySQL\Driver'); +// Throws: TypeError: Argument $port must be <= 65535, 70000 given -// Invalid $driverClass ('PDO\PgSQL\Driver' violates literal 'PDO\MySQL\Driver') -$service->findUser(42, 'PDO\PgSQL\Driver'); +// 3. Invalid Driver Class ('PDO\PgSQL\Driver' violates literal 'PDO\MySQL\Driver') +$service->configure(3306, 'PDO\PgSQL\Driver'); // Throws: TypeError: Argument $driverClass must be literal 'PDO\MySQL\Driver' ``` + --- ## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`) @@ -430,11 +491,11 @@ Define property shape contracts for generic objects or strictly for `stdClass` i ### Generic Object Shapes (`object{prop: type}`) -Accepts any object or `stdClass` matching the property shape: +Accepts any object instance or `stdClass` matching the declared property shape: ```php /** - * @param object{id: positive-int, name: non-empty-string} $user + * @param object{id: positive-int, name: non-empty-string, role?: string} $user */ function processObjectShape(object $user): void { @@ -446,11 +507,14 @@ $std->id = 42; $std->name = 'Alice'; processObjectShape($std); // Valid + +class CustomUser { public int $id = 42; public string $name = 'Alice'; } +processObjectShape(new CustomUser()); // Valid ``` ### Strict `stdClass` Shapes (`stdClass{prop: type}`) -Strictly requires a `stdClass` instance, rejecting custom class instances: +Strictly requires a `\stdClass` instance, rejecting custom class instances: ```php /** @@ -467,3 +531,7 @@ class CustomUser { public int $id = 42; public string $name = 'Alice'; } processStrictStdClass(new CustomUser()); // Throws: TypeError: processStrictStdClass(): Argument $payload must be an instance of stdClass ``` + +### Safe Inspection of Uninitialized Readonly Properties + +If an object instance contains uninitialized PHP 8.1+ `readonly` properties, TypePHP's `ObjectShapeValidator` safely inspects property initialization states using Reflection before attempting reads, throwing a clean `TypeError: property 'id' is uninitialized` without triggering PHP engine fatal crashes. diff --git a/docs/supported-types/callables-and-closures.md b/docs/supported-types/callables-and-closures.md index 52d5ded..34a9c9e 100644 --- a/docs/supported-types/callables-and-closures.md +++ b/docs/supported-types/callables-and-closures.md @@ -1,16 +1,17 @@ # Callables & Closures -TypePHP provides lazy runtime interception for callbacks, Closures, array callables, first-class callables, and PHPStan static-closure specifications. +TypePHP provides lazy runtime interception for callbacks, Closures, invokable objects (`__invoke`), array callables, PHP 8.1+ first-class callables, and PHPStan static-closure specifications. --- ## How Callback Interception Works (`CallableWrapper`) -When a callable parameter or local variable is annotated with a callable contract (such as `callable(positive-int): non-empty-string`), TypePHP wraps the callable in a lazy interceptor proxy: +When a function parameter, return value, or local variable is annotated with a callable contract (such as `callable(positive-int): non-empty-string`), TypePHP wraps the callable in a lazy interceptor proxy: 1. **Lazy Execution:** TypePHP does not execute the callback immediately when passed as an argument. -2. **Input Validation:** When the wrapped callback is invoked, TypePHP validates the arguments passed into the callback. +2. **Input Validation:** When the wrapped callback is invoked, TypePHP validates the arguments passed into the callback against the declared parameter types. 3. **Output Validation:** When the callback returns, TypePHP validates the returned value against the callback's declared return contract. +4. **Zero Overhead on Uncalled Callbacks:** If a callback is passed to a function but never invoked in that specific execution branch, zero validation overhead occurs. --- @@ -31,28 +32,98 @@ function processUserCallback(callable $callback): bool return $callback(10, 'Alice'); } -// Valid Callback +// 1. Valid Callback processUserCallback(function (int $id, string $name): bool { return $id > 0 && strlen($name) > 0; }); -// Invalid Callback (Returns integer 123 instead of bool) +// 2. Invalid Callback Return (Returns integer 123 instead of bool) processUserCallback(function (int $id, string $name): int { return 123; }); // Throws: TypeError: Callback $callback return value must be of type bool, int (123) given + +// 3. Invalid Callback Argument (Passing negative id into callback) +function badInvoker(callable $callback): bool +{ + return $callback(-5, 'Alice'); +} +// Throws: TypeError: Callback $callback $id must be of type positive-int, negative int (-5) given +``` + +--- + +## Generic Callables with Template Substitution (`@template T`) + +When a function uses generic template parameters (`@template T`), TypePHP dynamically substitutes `T` into the callable's parameter and return types based on the bound generic type: + +> **Deep Dive Guide:** For full details on generic templates, reified type inspection, and class bounds, see the dedicated [Generics & Bounds](/generics/generics-and-bounds) guide. + +```php +/** + * Generic transformer function + * + * @template T + * + * @param callable(T): T $transformer + * @param T $input + * + * @return T + */ +function transformValue(callable $transformer, mixed $input): mixed +{ + return $transformer($input); +} + +// 1. Valid Execution: Infers T = int, validates callback input (int) and return (int) +$double = fn (int $x): int => $x * 2; +transformValue($double, 21); // Returns 42 + +// 2. Valid Execution: Infers T = string, validates callback input (string) and return (string) +$shout = fn (string $s): string => strtoupper($s); +transformValue($shout, 'hello'); // Returns 'HELLO' + +// 3. Invalid Callback Return: T is inferred as int (from 10), but callback returns string ('invalid') +$badReturn = fn (int $x): string => 'invalid'; +transformValue($badReturn, 10); +// Throws: TypeError: Callback $transformer return value must be of type int, string 'invalid' given +``` + +### Generic Callables with Class Bounds (`@template T of Animal`) + +Restricting a callback's template parameter to a specific class hierarchy or interface: + +```php +/** + * @template T of Animal + * + * @param callable(T): non-empty-string $formatter + * @param T $animal + * + * @return non-empty-string + */ +function formatAnimal(callable $formatter, Animal $animal): string +{ + return $formatter($animal); +} + +// Valid Execution +formatAnimal(fn (Dog $d) => 'dog_label', new Dog()); + +// Invalid Return (Callback returns empty string violating non-empty-string) +formatAnimal(fn (Dog $d) => '', new Dog()); +// Throws: TypeError: Callback $formatter return value must be of type non-empty-string, empty string ('') given ``` --- ## Complex Parameter & Return Contracts in Callables -Because `CallableWrapper` delegates callback argument and return validation directly to TypePHP's central validator engine, **all complex types (generics, array shapes, lists, unions, intersections) are fully enforced inside callback signatures**: +Because `CallableWrapper` delegates argument and return validation directly to TypePHP's central validator engine, **all complex types (generics, array shapes, lists, unions, intersections) are fully enforced inside callback signatures**: ```php use App\Generics\Producer; use App\Models\Dog; -use App\Models\Car; /** * Callback accepting a generic Producer and list, returning an array shape @@ -76,15 +147,25 @@ executeComplexCallback(function (Producer $producer, array $ids): array { // Throws: TypeError: Callback $processor return value['count'] must be of type positive-int, negative int (-5) given ``` +> **Generics & Variance in Callables:** Need to enforce covariance (`Producer`) or contravariance (`Consumer`) within callback arguments? See [Demystifying Variance in Generics](/generics/generics-and-bounds#demystifying-variance-covariant-contravariant-invariant). + --- ## Strict Closure Instance Contracts (`Closure(T): R`) -When you specify `Closure(T): R` instead of `callable(T): R`, TypePHP strictly requires a native `Closure` instance, rejecting string function names or array callables: +When you specify `Closure(T): R` instead of `callable(T): R`, TypePHP strictly requires a native `\Closure` instance, rejecting string function names or invokable objects: ```php +class InvokableService +{ + public function __invoke(int $id): string + { + return "user_{$id}"; + } +} + /** - * Strictly requires a native Closure instance + * Strictly requires a native \Closure instance * * @param Closure(positive-int): non-empty-string $closure */ @@ -93,10 +174,14 @@ function executeClosureOnly(Closure $closure): string return $closure(42); } -// Valid Call +// 1. Valid Call (Native closure) executeClosureOnly(fn (int $id) => "user_{$id}"); -// Invalid Call (Passing string function name 'strlen' where Closure was required) +// 2. Invalid Call (Invokable object passed where \Closure was required) +executeClosureOnly(new InvokableService()); +// Throws: TypeError: Argument $closure must be of type Closure, App\InvokableService given + +// 3. Invalid Call (String function name 'strlen' passed where \Closure was required) executeClosureOnly('strlen'); // Throws: TypeError: Argument $closure must be of type Closure, string 'strlen' given ``` @@ -105,7 +190,7 @@ executeClosureOnly('strlen'); ## Array & First-Class Callables (PHP 8.1+) -TypePHP seamlessly intercepts array callables and PHP 8.1+ First-Class Callable syntax (`$obj->method(...)`): +TypePHP seamlessly intercepts instance method array callables, static method callables, and PHP 8.1+ First-Class Callable syntax (`$obj->method(...)`): ```php class UserService @@ -131,14 +216,60 @@ function executeFormatter(callable $formatter): string $service = new UserService(); -// 1. Instance Method Array Callable +// 1. PHP 8.1+ First-Class Callable Syntax on Instance Method +executeFormatter($service->formatUser(...)); // Valid + +// 2. PHP 8.1+ First-Class Callable Syntax on Static Method +executeFormatter(UserService::staticFormat(...)); // Valid + +// 3. Instance Method Array Callable executeFormatter([$service, 'formatUser']); // Valid -// 2. Static Method Array Callable +// 4. Static Method Array Callable executeFormatter([UserService::class, 'staticFormat']); // Valid +``` -// 3. PHP 8.1+ First-Class Callable Syntax -executeFormatter($service->formatUser(...)); // Valid +--- + +## Higher-Order & Curried Functions Returning Callables + +TypePHP recursively wraps functions that return other callback functions (e.g. factory pipelines, curried validators, and middleware handlers), enforcing argument and return contracts across all invocation stages: + +```php +class ValidationPipeline +{ + /** + * Method returning a curried validator function + * + * @return callable(positive-int): (callable(non-empty-string): bool) + */ + public function createLengthValidator(): callable + { + return function (int $minLength): callable { + return function (string $text) use ($minLength): bool { + return strlen($text) >= $minLength; + }; + }; + } +} + +$pipeline = new ValidationPipeline(); +$factory = $pipeline->createLengthValidator(); + +// Stage 1: Configure validator with minLength = 5 +$minFiveValidator = $factory(5); + +// Stage 2: Execute configured validator +$minFiveValidator('Hello World'); // Returns true +$minFiveValidator('Hi'); // Returns false + +// Invalid Stage 1 Argument ($minLength = -1 violates positive-int) +$factory(-1); +// Throws: TypeError: Return value Callback argument #1 must be of type positive-int, negative int (-1) given + +// Invalid Stage 2 Argument ($text = '' violates non-empty-string) +$minFiveValidator(''); +// Throws: TypeError: Return value: Returned callback Callback argument #1 must be of type non-empty-string ``` --- @@ -192,10 +323,10 @@ function processStaticClosure(Closure $closure): string return $closure(100); } -// Valid (Static closure) +// 1. Valid (Static closure) processStaticClosure(static fn (int $id) => "static_{$id}"); -// Invalid (Non-static closure bound to $this) +// 2. Invalid (Non-static closure bound to $this) processStaticClosure(fn (int $id) => "bound_{$id}"); // Throws: TypeError: Argument $closure must be a static Closure (not bound to $this) ``` @@ -215,33 +346,3 @@ $formatter(10, 'Alice'); // Valid $formatter(-5, 'Alice'); // Throws: TypeError: Variable $formatter: Callback argument #1 must be of type positive-int, negative int (-5) given ``` - -### Higher-Order Callables - -TypePHP supports higher-order callables returning other callables, validating both outer factory arguments and inner callback returns: - -```php -/** @var callable(positive-int): (callable(non-empty-string): non-empty-string) $factory */ -$factory = function (int $multiplier): callable { - return function (string $prefix) use ($multiplier): string { - if ($prefix === 'invalid') { - return ''; // Violates return non-empty-string! - } - - return str_repeat($prefix, $multiplier); - }; -}; - -// Valid Execution -$repeat3 = $factory(3); -$result = $repeat3('abc'); // Returns 'abcabcabc' - -// Invalid Factory Argument ($multiplier = -5 violates positive-int) -$factory(-5); -// Throws: TypeError: Variable $factory: Callback argument #1 must be of type positive-int, negative int (-5) given - -// Invalid Inner Callback Return Value ('invalid' returns '' violating non-empty-string) -$repeat3 = $factory(3); -$repeat3('invalid'); -// Throws: TypeError: Variable $factory: Callback return value must be of type non-empty-string, empty string ('') given -``` \ No newline at end of file diff --git a/docs/supported-types/iterators-and-generators.md b/docs/supported-types/iterators-and-generators.md index f09bc9c..9096e68 100644 --- a/docs/supported-types/iterators-and-generators.md +++ b/docs/supported-types/iterators-and-generators.md @@ -6,7 +6,7 @@ TypePHP provides lazy runtime validation for `Traversable` objects, `Iterator` i ## How Lazy Iteration Works (`IterableWrapper` & `IteratorProxy`) -When an iterator or generator is passed into a function accepting `Traversable` or returned from a function: +When an iterable or generator is passed into a function accepting `Traversable` or returned from a function: 1. **Zero Memory Spikes:** TypePHP does not convert the iterator to an array or load items eagerly into RAM. 2. **On-the-Fly Validation:** Keys (`K`) and values (`V`) are validated lazily during iteration at the exact moment each item is accessed inside `current()` or `yield`. 3. **Rewindability Preserved:** `IteratorProxy` unwraps and preserves iterator rewindability, allowing multiple `foreach` loops over the same wrapped iterator without crashing. @@ -36,14 +36,90 @@ function processTraversable(Traversable $items): array return $results; } -// Valid Call +// 1. Valid Call $iterator = new ArrayIterator(['item1' => 10, 'item2' => 20]); processTraversable($iterator); -// Invalid Call (Value -50 violates positive-int) +// 2. Invalid Call (Value -50 violates positive-int) $badIterator = new ArrayIterator(['item1' => 10, 'item2' => -50]); processTraversable($badIterator); // Throws: TypeError: Iterator $items value['item2'] must be of type positive-int, negative int (-50) given + +// 3. Invalid Call (Empty string violates non-empty-string key) +$badKeyIterator = new ArrayIterator(['' => 10]); +processTraversable($badKeyIterator); +// Throws: TypeError: Iterator $items key must be of type non-empty-string, empty string ('') given +``` + +--- + +## Generic Iterables with Template Substitution (`@template T`) + +When a function accepts generic iterables (`iterable` or `Traversable`), TypePHP dynamically substitutes `T` with the bound generic type and lazily validates items during iteration: + +> **Deep Dive Guide:** For comprehensive details on template bounds, covariance/contravariance, and runtime generic state inspection, see the [Generics & Bounds](/generics/generics-and-bounds) documentation. + +```php +use App\Models\Animal; +use App\Models\Dog; +use App\Models\Car; + +/** + * Generic stream processor where T is inferred from $sample + * + * @template T + * + * @param iterable $stream + * @param T $sample + * + * @return list + */ +function collectStream(iterable $stream, mixed $sample): array +{ + $collected = []; + foreach ($stream as $item) { + $collected[] = $item; + } + + return $collected; +} + +// 1. Valid Call: Infers T = int, validates all items against int +$intIterator = new ArrayIterator([10, 20, 30]); +collectStream($intIterator, 1); // Returns [10, 20, 30] + +// 2. Invalid Call: T is inferred as int (from 1), but iterator yields string ('invalid') +$badIterator = new ArrayIterator([10, 'invalid', 30]); +collectStream($badIterator, 1); +// Throws: TypeError: Iterator $stream value must be of type int, string 'invalid' given +``` + +### Generic Traversables with Class Bounds (`@template T of Animal`) + +```php +/** + * @template T of Animal + * + * @param Traversable $stream + * + * @return list + */ +function collectAnimalStream(Traversable $stream): array +{ + $collected = []; + foreach ($stream as $key => $animal) { + $collected[] = $animal; + } + + return $collected; +} + +// Valid Call +collectAnimalStream(new ArrayIterator(['dog1' => new Dog()])); + +// Invalid Call (Car is not an Animal) +collectAnimalStream(new ArrayIterator(['car1' => new Car()])); +// Throws: TypeError: Iterator $stream value must be of type App\Models\Animal, App\Models\Car given ``` --- @@ -76,11 +152,38 @@ foreach ($gen as $name => $score) { } ``` +### Generic Generators Yielding Template `T` (`Generator`) + +Generators seamlessly support generic template substitution for yielded values: + +```php +/** + * @template T + * + * @param T $item + * @param positive-int $count + * + * @return Generator + */ +function streamItem(mixed $item, int $count): Generator +{ + for ($i = 0; $i < $count; $i++) { + yield $i => $item; + } +} + +// Infers T = int, yields int values +$gen = streamItem(100, 3); +foreach ($gen as $k => $v) { + // [0 => 100, 1 => 100, 2 => 100] +} +``` + --- ## Generator Input Validation (`$gen->send()` / `TSend`) -TypePHP validates values sent into a generator via `$gen->send()` against the declared `TSend` template parameter: +TypePHP validates values sent into an interactive generator via `$gen->send()` against the declared `TSend` parameter: ```php /** @@ -97,15 +200,48 @@ function processInteractiveGenerator(): Generator $gen = processInteractiveGenerator(); $gen->current(); // Advances to first yield -// Valid Send (100 satisfies TSend = positive-int) +// 1. Valid Send (100 satisfies TSend = positive-int) $gen->send(100); -// Invalid Send (-500 violates TSend = positive-int) +// 2. Invalid Send (-500 violates TSend = positive-int) $gen = processInteractiveGenerator(); $gen->current(); $gen->send(-500); -// Throws: TypeError: processInteractiveGenerator(): Generator sent value (TSend) must be of type positive-int +// Throws: TypeError: processInteractiveGenerator(): Generator sent value (TSend) must be of type positive-int, negative int (-500) given +``` + +### Generic Interactive Generators (`Generator`) + +When `TSend` uses a generic template `T`, `$gen->send()` is dynamically validated against the bound generic type: + +```php +/** + * @template T + * + * @param T $initial + * + * @return Generator + */ +function streamInteractive(mixed $initial): Generator +{ + $current = $initial; + for ($i = 0; $i < 3; $i++) { + $input = yield $i => $current; + if ($input !== null) { + $current = $input; + } + } +} + +// Initial value 10 locks T = int +$gen = streamInteractive(10); +$gen->current(); + +$gen->send(20); // Valid (20 is int) + +$gen->send('invalid'); // Invalid: string violates T = int! +// Throws: TypeError: streamInteractive(): Generator sent value (TSend) must be of type int, string 'invalid' given ``` --- @@ -139,7 +275,6 @@ Because `GeneratorChecker` delegates key, value, and `TSend` validation directly ```php use App\Generics\Producer; use App\Models\Dog; -use App\Models\Car; /** * Generator yielding Array Shapes and accepting Array Shapes in $gen->send() @@ -156,13 +291,58 @@ function processComplexGenerator(): Generator $gen = processComplexGenerator(); $firstItem = $gen->current(); // Returns ['id' => 10, 'username' => 'Alice'] -// Valid Send +// 1. Valid Send $gen->send(['action' => 'approve']); -// Invalid Send ('action' => 'delete' violates 'approve'|'reject') +// 2. Invalid Send ('action' => 'delete' violates 'approve'|'reject') $gen = processComplexGenerator(); $gen->current(); $gen->send(['action' => 'delete']); // Throws: TypeError: processComplexGenerator(): Generator sent value (TSend)['action'] must be of type ('approve' | 'reject') ``` + +--- + +## Multi-Level `IteratorAggregate` Unwrapping & Method Forwarding + +When passing custom classes implementing `IteratorAggregate`, `IteratorProxy` recursively unwraps the inner iterator while preserving method forwarding and `Countable` support: + +```php +class NestedCollection implements IteratorAggregate, Countable +{ + public function __construct(private array $items = ['a' => 10, 'b' => 20]) {} + + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } + + public function getCustomMetadata(): string + { + return 'custom_metadata'; + } +} + +/** + * @param Traversable $collection + */ +function processCollection(Traversable $collection): void +{ + // 1. Iteration validates on-the-fly + foreach ($collection as $k => $v) { ... } + + // 2. Countable::count() is forwarded + echo count($collection); // 2 + + // 3. Custom methods forwarded via __call + echo $collection->getCustomMetadata(); // 'custom_metadata' +} + +processCollection(new NestedCollection()); +``` From fa715cd5d5528e0018cf15e47b9d55958f240d5a Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 15 Aug 2026 20:52:29 +0800 Subject: [PATCH 23/23] Fix path comparison in TypeError exception handling for CRLF tests --- tests/TypeChecking/Boundaries/CrlfLineDriftTest.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php index 1531af9..51489b5 100644 --- a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php +++ b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php @@ -133,8 +133,13 @@ $caught = false; } catch (TypeError $e) { $caught = true; - expect($e->getLine())->toBe(13) - ->and(str_replace('\\', '/', $e->getFile()))->toBe(str_replace('\\', '/', $crlfScriptPath)); + expect($e->getLine())->toBe(13); + + $expectedPath = realpath($crlfScriptPath) !== false ? realpath($crlfScriptPath) : $crlfScriptPath; + $actualPath = realpath($e->getFile()) !== false ? realpath($e->getFile()) : $e->getFile(); + + expect(strtolower(str_replace('\\', '/', (string) $actualPath))) + ->toBe(strtolower(str_replace('\\', '/', (string) $expectedPath))); } finally { @unlink($crlfScriptPath); @rmdir($tempDir);