From 132aa83093a1cffb338c63ee6542d8bd3a8d160a Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 11:17:35 +0800 Subject: [PATCH 1/5] Enhance type resolution by adding support for enums and improving docblock handling and nested type aliases --- src/Contract/ContractParser.php | 20 ++------ src/Contract/DocblockExtractor.php | 8 +++- src/Internal/DocblockNormalizer.php | 2 + .../Visitor/FunctionContractInjector.php | 7 ++- src/Resolver/SpecialTypeResolver.php | 27 +++++++++-- tests/Contract/DocblockExtractorTest.php | 9 ++++ .../Shopware/Config/MetricConfigProvider.php | 26 +++++++++++ tests/Fixtures/Shopware/Metric/Type.php | 18 ++++++++ tests/Internal/DocblockNormalizerTest.php | 10 ++++ tests/Resolver/SpecialTypeResolverTest.php | 11 +++++ .../Boundaries/ShopwareSyntaxBugTest.php | 46 +++++++++++++++++++ .../Visitor/FunctionContractInjectorTest.php | 21 +++++++++ typephp.php | 2 +- 13 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 tests/Fixtures/Shopware/Config/MetricConfigProvider.php create mode 100644 tests/Fixtures/Shopware/Metric/Type.php create mode 100644 tests/TypeChecking/Boundaries/ShopwareSyntaxBugTest.php diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 1f75c85..1071375 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -69,7 +69,7 @@ public static function parse(string $function): array if (str_contains($function, '::')) { [$className, $methodName] = explode('::', $function, 2); - if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) { /** @var class-string $className */ $refClass = new \ReflectionClass($className); if ($refClass->hasMethod($methodName)) { @@ -102,12 +102,6 @@ public static function parse(string $function): array /** * Parses and resolves the @var or @property docblock for a given class property. - * - * Resolution Steps: - * 1. Search class and parent class hierarchy for physical properties. - * 2. Search implemented interfaces (PHP 8.4 interface properties). - * 3. Fall back to class-level magic @property tags if enabled and physical property is not found. - * 4. Parse physical @var tags if not already resolved as a magic property. */ public static function parseProperty(string $className, string $propertyName): ?TypeNode { @@ -116,7 +110,7 @@ public static function parseProperty(string $className, string $propertyName): ? return self::$propertyCache[$cacheKey]; } - if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) { + if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) { return self::$propertyCache[$cacheKey] = null; } @@ -227,10 +221,6 @@ public static function parseProperty(string $className, string $propertyName): ? /** * Parses and resolves a class-level @method docblock for __call / __callStatic. * - * Resolution Steps: - * 1. Search class, parent, interface, and trait hierarchy for @method tags (excluding vendor files). - * 2. Substitute type aliases and resolve FQCNs for parameters and return types. - * * @return array{return: ?TypeNode, parameters: array, aliases: array, templates: array}|null */ public static function parseMagicMethod(string $className, string $methodName): ?array @@ -240,7 +230,7 @@ public static function parseMagicMethod(string $className, string $methodName): return self::$magicMethodCache[$cacheKey]; } - if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) { + if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className) && ! enum_exists($className)) { return self::$magicMethodCache[$cacheKey] = null; } @@ -349,7 +339,7 @@ public static function parseMagicMethod(string $className, string $methodName): */ public static function parseClassAliases(string $className): array { - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) { + if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { return []; } @@ -609,7 +599,7 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref * * @param array $aliases */ - private static function substituteAliases(TypeNode $node, array $aliases): TypeNode + public static function substituteAliases(TypeNode $node, array $aliases): TypeNode { if ($node instanceof IdentifierTypeNode) { if (isset($aliases[$node->name])) { diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 63df167..508ccab 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -130,14 +130,18 @@ public static function extractAliases( } } } + + foreach ($aliases as $name => $type) { + $aliases[$name] = ContractParser::substituteAliases($type, $aliases); + } } /** - * Resolves an imported type alias (@phpstan-import-type) from a target class or interface. + * Resolves an imported type alias (@phpstan-import-type) from a target class, interface, trait, or enum. */ public static function resolveImportedTypeAlias(string $fqcn, string $importedAlias): ?TypeNode { - if (! ClassNameValidator::isValid($fqcn) || (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn))) { + if (! ClassNameValidator::isValid($fqcn) || (! class_exists($fqcn) && ! interface_exists($fqcn) && ! trait_exists($fqcn) && ! enum_exists($fqcn))) { return null; } diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index f274f0b..b6b4c4c 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -28,6 +28,8 @@ final class DocblockNormalizer */ public static function normalize(string $doc): string { + $doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc; + $doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc; if (! str_contains($doc, '{')) { diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index df5f829..b2aefe7 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -34,8 +34,13 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): return; // Skip injecting contract checks for this specific function/method! } + $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; + $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); + $hasParam = $isClassMethod || str_contains($docText, '@param'); - $hasReturn = $isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'); + + // Never inject return checks into constructors, destructors, or clone methods + $hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')); if (! $hasParam && ! $hasReturn) { return; diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 5213cde..f723786 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -160,6 +160,8 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct */ public static function resolveForFile(TypeNode $node, string $file): TypeNode { + $file = str_replace('\\', '/', $file); + if ($node instanceof ThisTypeNode) { return clone $node; } @@ -256,7 +258,7 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio if (str_contains($context, '::')) { [$className, $methodName] = explode('::', $context, 2); - if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + if (class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className)) { /** @var class-string $className */ try { return new \ReflectionMethod($className, $methodName); @@ -620,7 +622,7 @@ private static function extractItemKey(mixed $keyName): string|int|null private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode { - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn))) { try { $refClass = new \ReflectionClass($fqcn); if ($refClass->hasConstant($constName)) { @@ -644,7 +646,7 @@ private static function resolveConstantOffsetValue(string $fqcn, string $constNa private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null { - if (class_exists($fqcn) || interface_exists($fqcn)) { + if (class_exists($fqcn) || interface_exists($fqcn) || enum_exists($fqcn)) { try { $refClass = new \ReflectionClass($fqcn); if ($refClass->hasConstant($constName)) { @@ -671,6 +673,7 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName) public static function seedFileMetadata(string $fileName, string $namespace, array $imports): void { if ($fileName !== '') { + $fileName = str_replace('\\', '/', $fileName); self::$fileNamespaces[$fileName] = $namespace; self::$fileUseImports[$fileName] = $imports; } @@ -700,14 +703,20 @@ public static function getUseImports(\ReflectionClass|\ReflectionFunction|\Refle */ public static function getUseImportsFromFile(string $fileName): array { - if ($fileName === '' || ! file_exists($fileName)) { + if ($fileName === '') { return []; } + $fileName = str_replace('\\', '/', $fileName); + if (isset(self::$fileUseImports[$fileName])) { return self::$fileUseImports[$fileName]; } + if (! file_exists($fileName)) { + return []; + } + $source = file_get_contents($fileName); if ($source === false) { return self::$fileUseImports[$fileName] = []; @@ -723,14 +732,20 @@ public static function getUseImportsFromFile(string $fileName): array */ public static function getNamespaceFromFile(string $fileName): string { - if ($fileName === '' || ! file_exists($fileName)) { + if ($fileName === '') { return ''; } + $fileName = str_replace('\\', '/', $fileName); + if (isset(self::$fileNamespaces[$fileName])) { return self::$fileNamespaces[$fileName]; } + if (! file_exists($fileName)) { + return ''; + } + $source = file_get_contents($fileName); if ($source === false) { return self::$fileNamespaces[$fileName] = ''; @@ -790,6 +805,8 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun */ public static function resolveFqcnForFile(string $name, string $file): string { + $file = str_replace('\\', '/', $file); + if (self::isBuiltInTypeKeyword($name)) { return $name; } diff --git a/tests/Contract/DocblockExtractorTest.php b/tests/Contract/DocblockExtractorTest.php index 47fbaf2..45bf236 100644 --- a/tests/Contract/DocblockExtractorTest.php +++ b/tests/Contract/DocblockExtractorTest.php @@ -5,6 +5,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use TypePHP\Contract\DocblockExtractor; use TypePHP\Tests\Fixtures\Services\UserService; +use TypePHP\Tests\Fixtures\Shopware\Metric\Type as MetricTypeEnum; use TypePHP\Tests\Fixtures\Types\UserApi; describe('DocblockExtractor Unit Tests', function () { @@ -57,6 +58,14 @@ expect($aliases)->toHaveKey('LocalUserShape'); }); + test('resolves imported type aliases from PHP 8.1 Enums', function () { + $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues'); + + expect($resolvedNode)->not()->toBeNull() + ->and((string) $resolvedNode)->toContain('histogram') + ; + }); + test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () { $doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list \$tags\n */"; diff --git a/tests/Fixtures/Shopware/Config/MetricConfigProvider.php b/tests/Fixtures/Shopware/Config/MetricConfigProvider.php new file mode 100644 index 0000000..2038f8d --- /dev/null +++ b/tests/Fixtures/Shopware/Config/MetricConfigProvider.php @@ -0,0 +1,26 @@ + $definitions + */ + public function __construct(array $definitions) + { + // Constructor logic... + } +} diff --git a/tests/Fixtures/Shopware/Metric/Type.php b/tests/Fixtures/Shopware/Metric/Type.php new file mode 100644 index 0000000..eead597 --- /dev/null +++ b/tests/Fixtures/Shopware/Metric/Type.php @@ -0,0 +1,18 @@ +toBe($doc); }); + test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () { + $doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */'; + $expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + + $doc2 = '/** @psalm-type UserRole = "admin"|"user" */'; + $expected2 = '/** @psalm-type UserRole "admin"|"user" */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + }); + test('converts stdClass shapes into intersection shapes', function () { $doc = '/** @param stdClass{id: int, name: string} $data */'; $expected = '/** @param (stdClass&object{id: int, name: string}) $data */'; diff --git a/tests/Resolver/SpecialTypeResolverTest.php b/tests/Resolver/SpecialTypeResolverTest.php index 4df2f72..6be411e 100644 --- a/tests/Resolver/SpecialTypeResolverTest.php +++ b/tests/Resolver/SpecialTypeResolverTest.php @@ -51,6 +51,17 @@ expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class); }); + test('normalizes backslashes to forward slashes in file metadata seeding and lookups', function () { + $windowsPath = 'C:\\project\\app\\Services\\UserService.php'; + SpecialTypeResolver::seedFileMetadata($windowsPath, 'App\\Services', ['User' => 'App\\Models\\User']); + + $forwardPath = 'C:/project/app/Services/UserService.php'; + + expect(SpecialTypeResolver::getNamespaceFromFile($forwardPath))->toBe('App\\Services') + ->and(SpecialTypeResolver::getUseImportsFromFile($forwardPath))->toHaveKey('User') + ; + }); + test('leaves built-in scalar and pseudo-type keywords untouched', function () { $ref = new ReflectionMethod(UserService::class, 'find'); diff --git a/tests/TypeChecking/Boundaries/ShopwareSyntaxBugTest.php b/tests/TypeChecking/Boundaries/ShopwareSyntaxBugTest.php new file mode 100644 index 0000000..bfc52c7 --- /dev/null +++ b/tests/TypeChecking/Boundaries/ShopwareSyntaxBugTest.php @@ -0,0 +1,46 @@ + [ + 'type' => 'counter', + 'description' => 'Counts plugin installs', + ], + 'plugin.response.time' => [ + 'type' => 'histogram', + 'description' => 'Measures response latency', + ], + ]; + + $provider = new MetricConfigProvider($validConfig); + + expect($provider)->toBeInstanceOf(MetricConfigProvider::class); + }); + + test('throws TypeError with expanded Enum union when shape array item violates imported type alias', function () { + $invalidConfig = [ + 'plugin.install.count' => [ + 'type' => 'invalid_metric_type', + 'description' => 'Counts plugin installs', + ], + ]; + + expect(fn () => new MetricConfigProvider($invalidConfig)) + ->toThrow(TypeError::class, "('histogram' | 'gauge' | 'counter' | 'updown_counter')") + ; + }); +}); diff --git a/tests/Visitor/FunctionContractInjectorTest.php b/tests/Visitor/FunctionContractInjectorTest.php index a7ee17d..a90a990 100644 --- a/tests/Visitor/FunctionContractInjectorTest.php +++ b/tests/Visitor/FunctionContractInjectorTest.php @@ -51,4 +51,25 @@ ->and($fn->stmts[2]->getAttribute('typephp_injected'))->toBeTrue() ; }); + + test('does not inject return checks into magic lifecycle methods like constructors', function () { + $fn = new Node\Stmt\ClassMethod('__construct', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('id')), + ], + 'stmts' => [], + ]); + + FunctionContractInjector::inject($fn); + + // Should have param check (setupScope, wrapCallable, wrapIterable) but NO return check + $hasReturn = false; + foreach ($fn->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Return_) { + $hasReturn = true; + } + } + + expect($hasReturn)->toBeFalse(); + }); }); diff --git a/typephp.php b/typephp.php index f5d8a62..bb9bf92 100644 --- a/typephp.php +++ b/typephp.php @@ -55,7 +55,7 @@ | TypePHP will automatically protect this directory from being re-transformed. */ 'cache' => true, - 'cache_dir' => null, + 'cache_dir' => __DIR__ . '/var/cache/', /* |-------------------------------------------------------------------------- From 618fd71db2cae64501b5c49f3e96065e1304a5f9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 12:02:44 +0800 Subject: [PATCH 2/5] Add support for multi-tier nested type aliases and enhance tests for alias resolution --- src/Contract/DocblockExtractor.php | 10 +- tests/Contract/DocblockExtractorTest.php | 29 ++++- tests/Fixtures/Types/NestedAliasChainedA.php | 14 ++ tests/Fixtures/Types/NestedAliasChainedB.php | 16 +++ tests/Fixtures/Types/NestedAliasService.php | 65 +++++++++ tests/Fixtures/Types/NestedAliasTypes.php | 16 +++ .../ArraysAndShapes/NestedTypeAliasesTest.php | 123 ++++++++++++++++++ 7 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/Types/NestedAliasChainedA.php create mode 100644 tests/Fixtures/Types/NestedAliasChainedB.php create mode 100644 tests/Fixtures/Types/NestedAliasService.php create mode 100644 tests/Fixtures/Types/NestedAliasTypes.php create mode 100644 tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 508ccab..267a5f9 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -131,6 +131,7 @@ public static function extractAliases( } } + // Expand nested/imported alias references inside extracted local aliases foreach ($aliases as $name => $type) { $aliases[$name] = ContractParser::substituteAliases($type, $aliases); } @@ -152,10 +153,11 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl if ($doc !== false) { $phpDocNode = self::parseDocString($doc); - foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) { - if ($aliasTag->alias === $importedAlias) { - return $aliasTag->type; - } + $targetAliases = []; + self::extractAliases($phpDocNode, $targetAliases, $ref); + + if (isset($targetAliases[$importedAlias])) { + return $targetAliases[$importedAlias]; } foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { diff --git a/tests/Contract/DocblockExtractorTest.php b/tests/Contract/DocblockExtractorTest.php index 45bf236..4fba1d4 100644 --- a/tests/Contract/DocblockExtractorTest.php +++ b/tests/Contract/DocblockExtractorTest.php @@ -6,6 +6,8 @@ use TypePHP\Contract\DocblockExtractor; use TypePHP\Tests\Fixtures\Services\UserService; use TypePHP\Tests\Fixtures\Shopware\Metric\Type as MetricTypeEnum; +use TypePHP\Tests\Fixtures\Types\NestedAliasChainedB; +use TypePHP\Tests\Fixtures\Types\NestedAliasService; use TypePHP\Tests\Fixtures\Types\UserApi; describe('DocblockExtractor Unit Tests', function () { @@ -58,7 +60,7 @@ expect($aliases)->toHaveKey('LocalUserShape'); }); - test('resolves imported type aliases from PHP 8.1 Enums', function () { + test('resolves imported type aliases from Enums', function () { $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(MetricTypeEnum::class, 'MetricTypeValues'); expect($resolvedNode)->not()->toBeNull() @@ -66,6 +68,31 @@ ; }); + test('resolves multi-tier chained imported type aliases (A -> B -> C)', function () { + $resolvedNode = DocblockExtractor::resolveImportedTypeAlias(NestedAliasChainedB::class, 'MidShape'); + + expect($resolvedNode)->not()->toBeNull() + ->and((string) $resolvedNode)->toContain('positive-int') + ->and((string) $resolvedNode)->toContain('non-empty-string') + ; + }); + + test('fully expands nested alias dependencies when extracting aliases from a class', function () { + $ref = new ReflectionClass(NestedAliasService::class); + $doc = $ref->getDocComment(); + expect($doc)->not()->toBeFalse(); + + $phpDocNode = DocblockExtractor::parseDocString($doc); + $aliases = []; + + DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref); + + expect($aliases)->toHaveKey('LocalRecordList') + ->and((string) $aliases['LocalRecordList'])->toContain('positive-int') + ->and((string) $aliases['LocalRecordList'])->toContain('active') + ; + }); + test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () { $doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list \$tags\n */"; diff --git a/tests/Fixtures/Types/NestedAliasChainedA.php b/tests/Fixtures/Types/NestedAliasChainedA.php new file mode 100644 index 0000000..53b130c --- /dev/null +++ b/tests/Fixtures/Types/NestedAliasChainedA.php @@ -0,0 +1,14 @@ + + * @phpstan-type ImportedRecordList list + * @phpstan-type AdminStatus 'admin_active' + * @phpstan-type UserStatus 'user_active' + * @phpstan-type UnionOfAliases AdminStatus|UserStatus + */ +class NestedAliasService +{ + /** + * Property hook validated against 3-class chained imported shape + * + * @var ChainedShape + */ + public array $chainedProperty { + get => $this->_chainedProperty; + set => $this->_chainedProperty = $value; + } + + private array $_chainedProperty = ['code' => 1, 'label' => 'init']; + + /** + * @param LocalRecordList $records + */ + public function saveLocalRecords(array $records): bool + { + return true; + } + + /** + * @param ImportedRecordList $records + */ + public function saveImportedRecords(array $records): bool + { + return true; + } + + /** + * @param ChainedShape $data + */ + public function saveChainedData(array $data): bool + { + return true; + } + + /** + * @param UnionOfAliases $status + */ + public function setUnionStatus(string $status): bool + { + return true; + } +} diff --git a/tests/Fixtures/Types/NestedAliasTypes.php b/tests/Fixtures/Types/NestedAliasTypes.php new file mode 100644 index 0000000..22622de --- /dev/null +++ b/tests/Fixtures/Types/NestedAliasTypes.php @@ -0,0 +1,16 @@ + 10, 'status' => 'active'], + ['id' => 20, 'status' => 'pending'], + ]; + + expect($service->saveLocalRecords($validRecords))->toBeTrue(); + }); + + test('throws TypeError when nested shape item violates local scalar alias', function () { + $service = new NestedAliasService(); + + $invalidRecords = [ + ['id' => 10, 'status' => 'active'], + ['id' => -5, 'status' => 'pending'], // -5 violates LocalId (positive-int) + ]; + + expect(fn () => $service->saveLocalRecords($invalidRecords)) + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; + }); + + test('throws TypeError when nested shape item violates local union alias', function () { + $service = new NestedAliasService(); + + $invalidRecords = [ + ['id' => 10, 'status' => 'archived'], // 'archived' violates LocalStatus ('active'|'pending') + ]; + + expect(fn () => $service->saveLocalRecords($invalidRecords)) + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") + ; + }); + }); + + describe('Imported Multi-Tier Nested Aliases', function () { + test('accepts valid nested records matching imported 3-tier alias definitions', function () { + $service = new NestedAliasService(); + + $validRecords = [ + ['id' => 100, 'status' => 'active'], + ['id' => 200, 'status' => 'pending'], + ]; + + expect($service->saveImportedRecords($validRecords))->toBeTrue(); + }); + + test('throws TypeError when nested shape item violates imported scalar alias', function () { + $service = new NestedAliasService(); + + $invalidRecords = [ + ['id' => -100, 'status' => 'active'], + ]; + + expect(fn () => $service->saveImportedRecords($invalidRecords)) + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; + }); + + test('throws TypeError when nested shape item violates imported union alias', function () { + $service = new NestedAliasService(); + + $invalidRecords = [ + ['id' => 100, 'status' => 'deleted'], + ]; + + expect(fn () => $service->saveImportedRecords($invalidRecords)) + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") + ; + }); + }); + + describe('Edge Case 1: 3-Class Chained Alias Imports (A -> B -> Service)', function () { + test('resolves chained type aliases imported across 3 separate classes', function () { + $service = new NestedAliasService(); + + expect($service->saveChainedData(['code' => 50, 'label' => 'valid']))->toBeTrue(); + + expect(fn () => $service->saveChainedData(['code' => -10, 'label' => 'valid'])) + ->toThrow(TypeError::class, "['code'] must be of type positive-int") + ; + + expect(fn () => $service->saveChainedData(['code' => 50, 'label' => ''])) + ->toThrow(TypeError::class, "['label'] must be of type non-empty-string") + ; + }); + }); + + describe('Edge Case 2: Unions Combining Independent Type Aliases', function () { + test('validates parameter against a union composed of separate type aliases', function () { + $service = new NestedAliasService(); + + expect($service->setUnionStatus('admin_active'))->toBeTrue(); + expect($service->setUnionStatus('user_active'))->toBeTrue(); + + expect(fn () => $service->setUnionStatus('guest_active')) + ->toThrow(TypeError::class, "('admin_active' | 'user_active')") + ; + }); + }); + + describe('Edge Case 3: Property Hooks with Imported Type Aliases', function () { + test('validates PHP 8.4 property hook write against imported 3-tier shape alias', function () { + $service = new NestedAliasService(); + + $service->chainedProperty = ['code' => 100, 'label' => 'updated']; + expect($service->chainedProperty['code'])->toBe(100); + + expect(fn () => $service->chainedProperty = ['code' => -1, 'label' => 'updated']) + ->toThrow(TypeError::class, "Property TypePHP\Tests\Fixtures\Types\NestedAliasService::\$chainedProperty['code'] must be of type positive-int"); + }); + }); +}); From 70647bdeacbabcd246e8bb047d0d1cee8d9500b6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 12:59:27 +0800 Subject: [PATCH 3/5] Implement parameter shifting and renaming in child classes, add tests for inheritance disambiguation --- src/Contract/ContractParser.php | 15 ++- .../Services/BaseShiftedAbstractService.php | 13 +++ .../Services/BaseShiftedMethodService.php | 31 ++++++ .../Services/BaseShiftedParentService.php | 24 ++++ .../Services/ChildShiftedMethodService.php | 26 +++++ .../Services/ChildShiftedOopService.php | 34 ++++++ .../Services/ChildShiftedParamService.php | 24 ++++ .../Services/ShiftedInterfaceContract.php | 14 +++ .../Fixtures/Services/ShiftedLoggerTrait.php | 17 +++ .../ParameterShiftInheritanceTest.php | 105 ++++++++++++++++++ 10 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/Services/BaseShiftedAbstractService.php create mode 100644 tests/Fixtures/Services/BaseShiftedMethodService.php create mode 100644 tests/Fixtures/Services/BaseShiftedParentService.php create mode 100644 tests/Fixtures/Services/ChildShiftedMethodService.php create mode 100644 tests/Fixtures/Services/ChildShiftedOopService.php create mode 100644 tests/Fixtures/Services/ChildShiftedParamService.php create mode 100644 tests/Fixtures/Services/ShiftedInterfaceContract.php create mode 100644 tests/Fixtures/Services/ShiftedLoggerTrait.php create mode 100644 tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 1071375..caf520c 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -537,9 +537,16 @@ private static function parseMethodHierarchyDocs( $targetParamName = $paramName; } else { $paramIndex = $hierNameToIndex[$paramName] ?? null; - $targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex])) - ? $baseParamNames[$paramIndex] - : null; + if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) { + $candidateName = $baseParamNames[$paramIndex]; + if (! isset($hierNameToIndex[$candidateName])) { + $targetParamName = $candidateName; + } else { + $targetParamName = null; + } + } else { + $targetParamName = null; + } } if ($targetParamName !== null && ! isset($types[$targetParamName])) { @@ -676,4 +683,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Services/BaseShiftedAbstractService.php b/tests/Fixtures/Services/BaseShiftedAbstractService.php new file mode 100644 index 0000000..45b98ec --- /dev/null +++ b/tests/Fixtures/Services/BaseShiftedAbstractService.php @@ -0,0 +1,13 @@ + $items + */ + abstract public function processItems(array $items): bool; +} \ No newline at end of file diff --git a/tests/Fixtures/Services/BaseShiftedMethodService.php b/tests/Fixtures/Services/BaseShiftedMethodService.php new file mode 100644 index 0000000..e02303a --- /dev/null +++ b/tests/Fixtures/Services/BaseShiftedMethodService.php @@ -0,0 +1,31 @@ + $batch + * @param non-empty-string $format + */ + public static function processBatch(array $batch, string $format): bool + { + return true; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/BaseShiftedParentService.php b/tests/Fixtures/Services/BaseShiftedParentService.php new file mode 100644 index 0000000..dc5e601 --- /dev/null +++ b/tests/Fixtures/Services/BaseShiftedParentService.php @@ -0,0 +1,24 @@ + $definitions + * @param array $repositoryMap + */ + public function __construct( + HelperService $helper, + array $definitions, + array $repositoryMap + ) { + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/ChildShiftedMethodService.php b/tests/Fixtures/Services/ChildShiftedMethodService.php new file mode 100644 index 0000000..1853b8d --- /dev/null +++ b/tests/Fixtures/Services/ChildShiftedMethodService.php @@ -0,0 +1,26 @@ + $userId, $name -> $userName, $options -> $userOptions + */ + public function updateUser(int $userId, string $userName, array $userOptions = []): bool + { + return parent::updateUser($userId, $userName, $userOptions); + } + + /** + * Static method renames $batch -> $itemBatch, $format -> $outputFormat, and adds optional $notify = false at Index 2 + * + * @param bool $notify + */ + public static function processBatch(array $itemBatch, string $outputFormat, bool $notify = false): bool + { + return parent::processBatch($itemBatch, $outputFormat); + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/ChildShiftedOopService.php b/tests/Fixtures/Services/ChildShiftedOopService.php new file mode 100644 index 0000000..1c5d936 --- /dev/null +++ b/tests/Fixtures/Services/ChildShiftedOopService.php @@ -0,0 +1,34 @@ + $statusCode, $token -> $authToken + */ + public function execute(int $statusCode, string $authToken): bool + { + return true; + } + + /** + * Implements Abstract method with renamed parameter: $items -> $itemList + */ + public function processItems(array $itemList): bool + { + return true; + } + + /** + * Overrides Trait method with renamed parameters: $level -> $logLevel, $message -> $logMessage + */ + public function logEvent(int $logLevel, string $logMessage): bool + { + return true; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/ChildShiftedParamService.php b/tests/Fixtures/Services/ChildShiftedParamService.php new file mode 100644 index 0000000..296a0b5 --- /dev/null +++ b/tests/Fixtures/Services/ChildShiftedParamService.php @@ -0,0 +1,24 @@ + $definitionMap at Index 2! + * + * @param array $definitionMap + * @param array $repositoryMap + */ + public function __construct( + string $prefix, + HelperService $helper, + array $definitionMap, + array $repositoryMap + ) { + parent::__construct($helper, $definitionMap, $repositoryMap); + } +} \ No newline at end of file diff --git a/tests/Fixtures/Services/ShiftedInterfaceContract.php b/tests/Fixtures/Services/ShiftedInterfaceContract.php new file mode 100644 index 0000000..add4522 --- /dev/null +++ b/tests/Fixtures/Services/ShiftedInterfaceContract.php @@ -0,0 +1,14 @@ + 'ProductDefinition'], + ['product' => 'ProductRepository'] + ); + + expect($service)->toBeInstanceOf(ChildShiftedParamService::class); + }); + }); + + describe('Instance Method Parameter Renaming', function () { + test('validates instance method parameters when child renames parameters', function () { + $service = new ChildShiftedMethodService(); + + expect($service->updateUser(42, 'Alice', ['active' => true]))->toBeTrue(); + }); + + test('throws TypeError when renamed $userId parameter fails parent inherited positive-int contract', function () { + $service = new ChildShiftedMethodService(); + + expect(fn () => $service->updateUser(-5, 'Alice', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int'); + }); + + test('throws TypeError when renamed $userName parameter fails parent inherited non-empty-string contract', function () { + $service = new ChildShiftedMethodService(); + + expect(fn () => $service->updateUser(42, '', ['active' => true])) + ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string'); + }); + + test('throws TypeError when renamed $userOptions parameter fails parent inherited shape contract', function () { + $service = new ChildShiftedMethodService(); + + expect(fn () => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) + ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool"); + }); + }); + + describe('Static Method Parameter Renaming & Optional Parameters', function () { + test('validates static method parameters when child renames parameters and adds optional parameter', function () { + expect(ChildShiftedMethodService::processBatch([10, 20], 'json', true))->toBeTrue(); + }); + + test('throws TypeError when renamed $itemBatch parameter fails parent contract on static method', function () { + expect(fn () => ChildShiftedMethodService::processBatch([10, -5], 'json')) + ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int'); + }); + + test('throws TypeError when renamed $outputFormat parameter fails parent contract on static method', function () { + expect(fn () => ChildShiftedMethodService::processBatch([10, 20], '')) + ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string'); + }); + }); + + describe('Interfaces, Abstract Classes, and Traits', function () { + test('inherits and validates contracts from Interfaces with renamed parameters', function () { + $service = new ChildShiftedOopService(); + + expect($service->execute(200, 'valid_token'))->toBeTrue(); + + expect(fn () => $service->execute(-10, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); + + expect(fn () => $service->execute(200, '')) + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); + }); + + test('inherits and validates contracts from Abstract Classes with renamed parameters', function () { + $service = new ChildShiftedOopService(); + + expect($service->processItems([10, 20, 30]))->toBeTrue(); + + expect(fn () => $service->processItems([10, -5, 30])) + ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int'); + }); + + test('inherits and validates contracts from Traits with renamed parameters', function () { + $service = new ChildShiftedOopService(); + + expect($service->logEvent(1, 'info_message'))->toBeTrue(); + + expect(fn () => $service->logEvent(-1, 'info_message')) + ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int'); + + expect(fn () => $service->logEvent(1, '')) + ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string'); + }); + }); +}); \ No newline at end of file From c65fa291f14a6c3e8648c201f82266cc94d4d21d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 14:10:42 +0800 Subject: [PATCH 4/5] Refactor code to ensure proper newline at end of file in multiple service and type files; update cache directory configuration to null --- src/Contract/ContractParser.php | 2 +- .../Services/BaseShiftedAbstractService.php | 2 +- .../Services/BaseShiftedMethodService.php | 2 +- .../Services/BaseShiftedParentService.php | 2 +- .../Services/ChildMagicMethodService.php | 40 ++++++++++++ .../Services/ChildShiftedMethodService.php | 2 +- .../Services/ChildShiftedOopService.php | 2 +- .../Services/ChildShiftedParamService.php | 2 +- .../Services/ChildShiftedTraitAppService.php | 10 +++ .../Services/ShiftedFulfillmentTrait.php | 16 +++++ .../Services/ShiftedInterfaceContract.php | 2 +- .../Fixtures/Services/ShiftedLoggerTrait.php | 2 +- .../Services/ShiftedTraitInterface.php | 16 +++++ tests/Fixtures/Types/BaseMagicMethodClass.php | 1 + tests/Fixtures/Types/MagicMethodFixture.php | 10 +-- .../Boundaries/MagicMethodsTest.php | 64 +++++++++++++------ .../ParameterShiftInheritanceTest.php | 55 ++++++++++++---- typephp.php | 2 +- 18 files changed, 187 insertions(+), 45 deletions(-) create mode 100644 tests/Fixtures/Services/ChildMagicMethodService.php create mode 100644 tests/Fixtures/Services/ChildShiftedTraitAppService.php create mode 100644 tests/Fixtures/Services/ShiftedFulfillmentTrait.php create mode 100644 tests/Fixtures/Services/ShiftedTraitInterface.php diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index caf520c..7599ff3 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -683,4 +683,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/BaseShiftedAbstractService.php b/tests/Fixtures/Services/BaseShiftedAbstractService.php index 45b98ec..bbcd8f1 100644 --- a/tests/Fixtures/Services/BaseShiftedAbstractService.php +++ b/tests/Fixtures/Services/BaseShiftedAbstractService.php @@ -10,4 +10,4 @@ abstract class BaseShiftedAbstractService implements ShiftedInterfaceContract * @param list $items */ abstract public function processItems(array $items): bool; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/BaseShiftedMethodService.php b/tests/Fixtures/Services/BaseShiftedMethodService.php index e02303a..e779d6c 100644 --- a/tests/Fixtures/Services/BaseShiftedMethodService.php +++ b/tests/Fixtures/Services/BaseShiftedMethodService.php @@ -28,4 +28,4 @@ public static function processBatch(array $batch, string $format): bool { return true; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/BaseShiftedParentService.php b/tests/Fixtures/Services/BaseShiftedParentService.php index dc5e601..8a97842 100644 --- a/tests/Fixtures/Services/BaseShiftedParentService.php +++ b/tests/Fixtures/Services/BaseShiftedParentService.php @@ -21,4 +21,4 @@ public function __construct( array $repositoryMap ) { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Services/ChildMagicMethodService.php b/tests/Fixtures/Services/ChildMagicMethodService.php new file mode 100644 index 0000000..43aec83 --- /dev/null +++ b/tests/Fixtures/Services/ChildMagicMethodService.php @@ -0,0 +1,40 @@ + $ids[0] ?? 1, @@ -37,7 +37,7 @@ public function __call(string $name, array $arguments): mixed } if ($name === 'getProducer') { - return $arguments[0] ?? null; + return $arguments[0] ?? $arguments['producer'] ?? null; } if ($name === 'checkCollection') { @@ -45,7 +45,7 @@ public function __call(string $name, array $arguments): mixed } if ($name === 'saveUser') { - return $arguments[0] ?? null; + return $arguments[0] ?? $arguments['user'] ?? null; } return null; @@ -54,7 +54,7 @@ public function __call(string $name, array $arguments): mixed public static function __callStatic(string $name, array $arguments): mixed { if ($name === 'fetchList') { - return $arguments; + return array_values($arguments); } return null; diff --git a/tests/TypeChecking/Boundaries/MagicMethodsTest.php b/tests/TypeChecking/Boundaries/MagicMethodsTest.php index 789933a..8833830 100644 --- a/tests/TypeChecking/Boundaries/MagicMethodsTest.php +++ b/tests/TypeChecking/Boundaries/MagicMethodsTest.php @@ -6,7 +6,7 @@ use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Generics\Producer; -use TypePHP\Tests\Fixtures\Types\ChildInheritedMagicMethodFixture; +use TypePHP\Tests\Fixtures\Services\ChildMagicMethodService; use TypePHP\Tests\Fixtures\Types\CountableArrayAccess; use TypePHP\Tests\Fixtures\Types\CountableOnly; use TypePHP\Tests\Fixtures\Types\MagicMethodFixture; @@ -27,11 +27,11 @@ expect($fixture->processId(42, 'Alice'))->toBe(42); expect(fn () => $fixture->processId(-5, 'Alice')) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument $id must be of type positive-int, negative int (-5) given') + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Types\MagicMethodFixture::processId(): Argument $id must be of type positive-int, negative int (-5) given') ; expect(fn () => $fixture->processId(42, '')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument \$name must be of type non-empty-string, empty string ('') given") + ->toThrow(TypeError::class, "TypePHP\Tests\Fixtures\Types\MagicMethodFixture::processId(): Argument \$name must be of type non-empty-string, empty string ('') given") ; }); @@ -39,7 +39,33 @@ expect(MagicMethodFixture::fetchList(1, 2, 3))->toBe([1, 2, 3]); expect(fn () => MagicMethodFixture::fetchList(1, 2, 'hello')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::fetchList(): Argument \$items[2] must be of type int, string 'hello' given") + ->toThrow(TypeError::class, "TypePHP\Tests\Fixtures\Types\MagicMethodFixture::fetchList(): Argument \$items[2] must be of type int, string 'hello' given") + ; + }); + }); + + describe('PHP 8.0+ Named Arguments & Swapped Positions on Magic Methods', function () { + test('validates named arguments passed in swapped order to dynamic magic method', function () { + $fixture = new MagicMethodFixture(); + + expect($fixture->processId(name: 'Alice', id: 42))->toBe(42); + + expect(fn () => $fixture->processId(name: 'Alice', id: -5)) + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Types\MagicMethodFixture::processId(): Argument $id must be of type positive-int') + ; + }); + + test('validates named arguments in swapped order on inherited magic method from parent class', function () { + $service = new ChildMagicMethodService(); + + expect($service->calculateScore(category: 'sports', baseScore: 100))->toBe(100); + + expect(fn () => $service->calculateScore(category: 'sports', baseScore: -50)) + ->toThrow(TypeError::class, 'Argument $baseScore must be of type positive-int') + ; + + expect(fn () => $service->calculateScore(category: '', baseScore: 100)) + ->toThrow(TypeError::class, 'Argument $category must be of type non-empty-string') ; }); }); @@ -52,11 +78,11 @@ expect($result)->toBe(['id' => 10, 'tags' => ['php', 'typephp']]); expect(fn () => $fixture->buildPayload([10, -5], 'active')) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument $ids[1] must be of type positive-int') + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Types\MagicMethodFixture::buildPayload(): Argument $ids[1] must be of type positive-int') ; expect(fn () => $fixture->buildPayload([10, 20], 'archived')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')") + ->toThrow(TypeError::class, "TypePHP\Tests\Fixtures\Types\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')") ; }); }); @@ -70,7 +96,7 @@ $carProducer = new Producer(new Car()); expect(fn () => $fixture->getProducer($carProducer)) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::getProducer(): Argument $producer expects TypePHP\\Tests\\Fixtures\\Generics\\Producer') + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Types\MagicMethodFixture::getProducer(): Argument $producer expects TypePHP\Tests\Fixtures\Generics\Producer') ; }); }); @@ -83,7 +109,7 @@ expect($fixture->checkCollection(new CountableArrayAccess()))->toBeTrue(); expect(fn () => $fixture->checkCollection(new CountableOnly())) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::checkCollection(): Argument $collection must be of type ((Countable & ArrayAccess) | null)') + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Types\MagicMethodFixture::checkCollection(): Argument $collection must be of type ((Countable & ArrayAccess) | null)') ; }); }); @@ -97,28 +123,28 @@ $badUser = ['id' => 10, 'role' => 'superadmin']; expect(fn () => $fixture->saveUser($badUser)) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::saveUser(): Argument \$user['role'] must be of type ('admin' | 'user')") + ->toThrow(TypeError::class, "TypePHP\Tests\Fixtures\Types\MagicMethodFixture::saveUser(): Argument \$user['role'] must be of type ('admin' | 'user')") ; }); }); describe('Inheritance with @method (Classes, Interfaces & Traits)', function () { test('inherits @method contracts from parent classes, interfaces, and traits', function () { - $fixture = new ChildInheritedMagicMethodFixture(); + $service = new ChildMagicMethodService(); - expect($fixture->parentMethod(100))->toBe(100); - expect(fn () => $fixture->parentMethod(-5)) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::parentMethod(): Argument $id must be of type positive-int') + expect($service->parentMethod(100))->toBe(100); + expect(fn () => $service->parentMethod(-5)) + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Services\ChildMagicMethodService::parentMethod(): Argument $id must be of type positive-int') ; - expect($fixture->interfaceMethod('hello'))->toBe('hello'); - expect(fn () => $fixture->interfaceMethod('')) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::interfaceMethod(): Argument $title must be of type non-empty-string') + expect($service->interfaceMethod('hello'))->toBe('hello'); + expect(fn () => $service->interfaceMethod('')) + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Services\ChildMagicMethodService::interfaceMethod(): Argument $title must be of type non-empty-string') ; - expect($fixture->traitMethod('admin'))->toBeTrue(); - expect(fn () => $fixture->traitMethod('guest')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::traitMethod(): Argument \$role must be of type ('admin' | 'user')") + expect($service->traitMethod('admin'))->toBeTrue(); + expect(fn () => $service->traitMethod('guest')) + ->toThrow(TypeError::class, "TypePHP\Tests\Fixtures\Services\ChildMagicMethodService::traitMethod(): Argument \$role must be of type ('admin' | 'user')") ; }); }); diff --git a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php index f633179..43d9b08 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php @@ -2,11 +2,21 @@ declare(strict_types=1); +use TypePHP\Internal\Config; use TypePHP\Tests\Fixtures\Services\ChildShiftedMethodService; use TypePHP\Tests\Fixtures\Services\ChildShiftedOopService; use TypePHP\Tests\Fixtures\Services\ChildShiftedParamService; +use TypePHP\Tests\Fixtures\Services\ChildShiftedTraitAppService; use TypePHP\Tests\Fixtures\Services\HelperService; +beforeEach(function () { + Config::reset(); +}); + +afterEach(function () { + Config::reset(); +}); + describe('Parameter Shift & Renaming Inheritance Disambiguation', function () { describe('Constructor Parameter Shifts', function () { test('prevents parent constructor param contracts from mis-mapping onto shifted child params', function () { @@ -34,21 +44,24 @@ $service = new ChildShiftedMethodService(); expect(fn () => $service->updateUser(-5, 'Alice', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int') + ; }); test('throws TypeError when renamed $userName parameter fails parent inherited non-empty-string contract', function () { $service = new ChildShiftedMethodService(); expect(fn () => $service->updateUser(42, '', ['active' => true])) - ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string') + ; }); test('throws TypeError when renamed $userOptions parameter fails parent inherited shape contract', function () { $service = new ChildShiftedMethodService(); expect(fn () => $service->updateUser(42, 'Alice', ['active' => 'not_bool'])) - ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool"); + ->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool") + ; }); }); @@ -59,12 +72,14 @@ test('throws TypeError when renamed $itemBatch parameter fails parent contract on static method', function () { expect(fn () => ChildShiftedMethodService::processBatch([10, -5], 'json')) - ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int') + ; }); test('throws TypeError when renamed $outputFormat parameter fails parent contract on static method', function () { expect(fn () => ChildShiftedMethodService::processBatch([10, 20], '')) - ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string') + ; }); }); @@ -75,10 +90,12 @@ expect($service->execute(200, 'valid_token'))->toBeTrue(); expect(fn () => $service->execute(-10, 'valid_token')) - ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int') + ; expect(fn () => $service->execute(200, '')) - ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string') + ; }); test('inherits and validates contracts from Abstract Classes with renamed parameters', function () { @@ -87,7 +104,8 @@ expect($service->processItems([10, 20, 30]))->toBeTrue(); expect(fn () => $service->processItems([10, -5, 30])) - ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int') + ; }); test('inherits and validates contracts from Traits with renamed parameters', function () { @@ -96,10 +114,25 @@ expect($service->logEvent(1, 'info_message'))->toBeTrue(); expect(fn () => $service->logEvent(-1, 'info_message')) - ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int') + ; expect(fn () => $service->logEvent(1, '')) - ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string') + ; + }); + + test('inherits Interface contracts when method is fulfilled by Trait with renamed parameters', function () { + $service = new ChildShiftedTraitAppService(); + + expect($service->runAction(100, 'valid_token'))->toBeTrue(); + + expect(fn () => $service->runAction(-5, 'valid_token')) + ->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int') + ; + + expect(fn () => $service->runAction(100, '')) + ->toThrow(TypeError::class, 'Argument $actionToken must be of type non-empty-string'); }); }); -}); \ No newline at end of file +}); diff --git a/typephp.php b/typephp.php index bb9bf92..f5d8a62 100644 --- a/typephp.php +++ b/typephp.php @@ -55,7 +55,7 @@ | TypePHP will automatically protect this directory from being re-transformed. */ 'cache' => true, - 'cache_dir' => __DIR__ . '/var/cache/', + 'cache_dir' => null, /* |-------------------------------------------------------------------------- From ee7b5e2446e062599e7dddaa87f91a75cff45c92 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 14 Aug 2026 14:52:12 +0800 Subject: [PATCH 5/5] Refactor NestedAliasService and NestedTypeAliasesTest to remove unused chainedProperty and clean up type error assertions --- tests/Fixtures/Types/NestedAliasService.php | 15 +------ .../ArraysAndShapes/NestedTypeAliasesTest.php | 41 +++++-------------- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/tests/Fixtures/Types/NestedAliasService.php b/tests/Fixtures/Types/NestedAliasService.php index af1ddc6..217e57a 100644 --- a/tests/Fixtures/Types/NestedAliasService.php +++ b/tests/Fixtures/Types/NestedAliasService.php @@ -13,24 +13,13 @@ * @phpstan-type LocalRecordShape array{id: LocalId, status: LocalStatus} * @phpstan-type LocalRecordList list * @phpstan-type ImportedRecordList list + * * @phpstan-type AdminStatus 'admin_active' * @phpstan-type UserStatus 'user_active' * @phpstan-type UnionOfAliases AdminStatus|UserStatus */ class NestedAliasService { - /** - * Property hook validated against 3-class chained imported shape - * - * @var ChainedShape - */ - public array $chainedProperty { - get => $this->_chainedProperty; - set => $this->_chainedProperty = $value; - } - - private array $_chainedProperty = ['code' => 1, 'label' => 'init']; - /** * @param LocalRecordList $records */ @@ -62,4 +51,4 @@ public function setUnionStatus(string $status): bool { return true; } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php b/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php index e61f181..719a4cf 100644 --- a/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/NestedTypeAliasesTest.php @@ -22,24 +22,22 @@ $invalidRecords = [ ['id' => 10, 'status' => 'active'], - ['id' => -5, 'status' => 'pending'], // -5 violates LocalId (positive-int) + ['id' => -5, 'status' => 'pending'], ]; expect(fn () => $service->saveLocalRecords($invalidRecords)) - ->toThrow(TypeError::class, "['id'] must be of type positive-int") - ; + ->toThrow(TypeError::class, "['id'] must be of type positive-int"); }); test('throws TypeError when nested shape item violates local union alias', function () { $service = new NestedAliasService(); $invalidRecords = [ - ['id' => 10, 'status' => 'archived'], // 'archived' violates LocalStatus ('active'|'pending') + ['id' => 10, 'status' => 'archived'], ]; expect(fn () => $service->saveLocalRecords($invalidRecords)) - ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") - ; + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')"); }); }); @@ -59,12 +57,11 @@ $service = new NestedAliasService(); $invalidRecords = [ - ['id' => -100, 'status' => 'active'], + ['id' => -100, 'status' => 'active'], ]; expect(fn () => $service->saveImportedRecords($invalidRecords)) - ->toThrow(TypeError::class, "['id'] must be of type positive-int") - ; + ->toThrow(TypeError::class, "['id'] must be of type positive-int"); }); test('throws TypeError when nested shape item violates imported union alias', function () { @@ -75,8 +72,7 @@ ]; expect(fn () => $service->saveImportedRecords($invalidRecords)) - ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')") - ; + ->toThrow(TypeError::class, "['status'] must be of type ('active' | 'pending')"); }); }); @@ -87,12 +83,10 @@ expect($service->saveChainedData(['code' => 50, 'label' => 'valid']))->toBeTrue(); expect(fn () => $service->saveChainedData(['code' => -10, 'label' => 'valid'])) - ->toThrow(TypeError::class, "['code'] must be of type positive-int") - ; + ->toThrow(TypeError::class, "['code'] must be of type positive-int"); expect(fn () => $service->saveChainedData(['code' => 50, 'label' => ''])) - ->toThrow(TypeError::class, "['label'] must be of type non-empty-string") - ; + ->toThrow(TypeError::class, "['label'] must be of type non-empty-string"); }); }); @@ -104,20 +98,7 @@ expect($service->setUnionStatus('user_active'))->toBeTrue(); expect(fn () => $service->setUnionStatus('guest_active')) - ->toThrow(TypeError::class, "('admin_active' | 'user_active')") - ; + ->toThrow(TypeError::class, "('admin_active' | 'user_active')"); }); }); - - describe('Edge Case 3: Property Hooks with Imported Type Aliases', function () { - test('validates PHP 8.4 property hook write against imported 3-tier shape alias', function () { - $service = new NestedAliasService(); - - $service->chainedProperty = ['code' => 100, 'label' => 'updated']; - expect($service->chainedProperty['code'])->toBe(100); - - expect(fn () => $service->chainedProperty = ['code' => -1, 'label' => 'updated']) - ->toThrow(TypeError::class, "Property TypePHP\Tests\Fixtures\Types\NestedAliasService::\$chainedProperty['code'] must be of type positive-int"); - }); - }); -}); +}); \ No newline at end of file