diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index b8bde46..88bf37a 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -154,7 +154,11 @@ public static function parseProperty(string $className, string $propertyName): ? } $typeNode = $varTags[0]->type; + $aliases = []; + $templates = []; + self::parseClassLevelDocs($declaringClass, $templates, $aliases); + DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); $typeNode = self::substituteAliases($typeNode, $aliases); @@ -180,14 +184,9 @@ public static function parseClassAliases(string $className): array try { /** @var class-string $className */ $refClass = new \ReflectionClass($className); - $doc = $refClass->getDocComment(); - if ($doc === false) { - return []; - } - - $phpDocNode = DocblockExtractor::parseDocString($doc); $aliases = []; - DocblockExtractor::extractAliases($phpDocNode, $aliases, $refClass); + $templates = []; + self::parseClassLevelDocs($refClass, $templates, $aliases); return $aliases; } catch (\Throwable $e) { @@ -462,7 +461,7 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -479,14 +478,14 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof UnionTypeNode) { return new UnionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } if ($node instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 31b5d41..b54f973 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -114,15 +114,19 @@ public static function extractAliases( \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref ): void { foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) { - $aliases[$aliasTag->alias] = $aliasTag->type; + if (!isset($aliases[$aliasTag->alias])) { + $aliases[$aliasTag->alias] = $aliasTag->type; + } } foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { $localName = $importTag->importedAs ?? $importTag->importedAlias; - $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); - $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); - if ($resolvedType !== null) { - $aliases[$localName] = $resolvedType; + if (!isset($aliases[$localName])) { + $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); + $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); + if ($resolvedType !== null) { + $aliases[$localName] = $resolvedType; + } } } } diff --git a/src/Validator/UnionValidator.php b/src/Validator/UnionValidator.php index 101cbbc..59af4d1 100644 --- a/src/Validator/UnionValidator.php +++ b/src/Validator/UnionValidator.php @@ -20,12 +20,34 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var UnionTypeNode $unionNode */ $unionNode = $node; + $deepErrors = []; + foreach ($unionNode->types as $type) { - if ($registry->validate($value, $type, $context) === null) { + $err = $registry->validate($value, $type, $context); + if ($err === null) { return null; } + + $msg = $err->getMessage(); + + if ( + str_starts_with($msg, $context . '[') || + str_starts_with($msg, $context . '->') || + str_starts_with($msg, $context . ' is missing required') || + str_starts_with($msg, $context . ' contains unsealed') || + str_starts_with($msg, $context . ' property') || + str_starts_with($msg, $context . ' key') || + str_starts_with($msg, $context . ' value') || + str_starts_with($msg, $context . ' extra key') + ) { + $deepErrors[] = $err; + } + } + + if (\count($deepErrors) > 0) { + return $deepErrors[0]; } return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php b/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php new file mode 100644 index 0000000..076360b --- /dev/null +++ b/tests/Fixtures/Types/Imported/ClassUsingTraitWithAlias.php @@ -0,0 +1,10 @@ + 3, 'strict' => true]; + + public function load(): void + { + $this->pluginInfos = [ + ['name' => 'SwagPayPal', 'active' => true], + ]; + } + + public function loadBad(): void + { + $this->pluginInfos = [ + ['name' => 'SwagPayPal', 'active' => 'yes'], + ]; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Types/Imported/KernelPluginLoader.php b/tests/Fixtures/Types/Imported/KernelPluginLoader.php new file mode 100644 index 0000000..8906059 --- /dev/null +++ b/tests/Fixtures/Types/Imported/KernelPluginLoader.php @@ -0,0 +1,15 @@ + */ + public array $pluginInfos = []; +} \ No newline at end of file diff --git a/tests/Fixtures/Types/Imported/TraitWithAlias.php b/tests/Fixtures/Types/Imported/TraitWithAlias.php new file mode 100644 index 0000000..80c8a83 --- /dev/null +++ b/tests/Fixtures/Types/Imported/TraitWithAlias.php @@ -0,0 +1,14 @@ + 0, 'y' => 0]; +} \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php new file mode 100644 index 0000000..e748c21 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php @@ -0,0 +1,46 @@ +load(); + + expect($loader->pluginInfos)->toHaveCount(1); + expect($loader->pluginInfos[0]['name'])->toBe('SwagPayPal'); + }); + + test('fails correctly when the imported array shape is actually violated', function () { + $loader = new DbalKernelPluginLoader(); + + expect(fn() => $loader->loadBad()) + ->toThrow(\TypeError::class, "['active']"); + }); + + test('resolves overridden aliases in child class without breaking parent inheritance', function () { + $loader = new DbalKernelPluginLoader(); + + $loader->config = ['retries' => 5, 'strict' => false]; + expect($loader->config)->toBe(['retries' => 5, 'strict' => false]); + + expect(fn() => $loader->config = ['retries' => -1, 'strict' => false]) + ->toThrow(\TypeError::class, "['retries'] must be of type positive-int"); + }); + + test('resolves aliases defined on traits applied to properties inside the trait', function () { + $instance = new ClassUsingTraitWithAlias(); + + expect($instance->coordinates)->toBe(['x' => 0, 'y' => 0]); + + $instance->coordinates = ['x' => 10, 'y' => 20]; + expect($instance->coordinates['x'])->toBe(10); + + expect(fn() => $instance->coordinates = ['x' => 10, 'y' => 'invalid']) + ->toThrow(\TypeError::class, "['y'] must be of type int"); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php new file mode 100644 index 0000000..5454634 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -0,0 +1,135 @@ +}|null $payload + */ +function testDeepUnionError(mixed $payload): bool +{ + return true; +} + +/** + * @return array{name: string, args: list}|null + */ +function testAttributeCompilerSim(): ?array +{ + return [ + 'name' => 'Field', + 'args' => ['column', 'property', new \stdClass()], + ]; +} + +/** + * @param object{id: positive-int, profile: object{name: non-empty-string}}|null $user + */ +function testDeepObjectShapeUnion(mixed $user): bool +{ + return true; +} + +/** + * @param object{id: int, role: string}|null $data + */ +function testMissingObjectPropertyUnion(mixed $data): bool +{ + return true; +} + +/** + * @param object{name: string}|null $data + */ +function testUninitializedObjectPropertyUnion(mixed $data): bool +{ + return true; +} + +/** + * @param (array{type: 'A', data: array{score: positive-int}} | array{type: 'B', data: array{code: non-empty-string}})|null $discriminated + */ +function testDiscriminatedUnionDeepError(mixed $discriminated): bool +{ + return true; +} + +class PropertyUnionFixture +{ + /** + * @var array{config: array{enabled: bool}}|null + */ + public ?array $settings = null; +} + +describe('Union Deep Error Bubbling', function () { + test('surfaces deep array shape error instead of generic union error', function () { + expect(fn() => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]])) + ->toThrow(\TypeError::class, "Argument \$payload['tags'][1] must be of type (string | int)"); + }); + + test('surfaces deep return shape error mimicking AttributeEntityCompiler', function () { + expect(fn() => testAttributeCompilerSim()) + ->toThrow(\TypeError::class, "Return value['args'][2] must be of type (string | int | false)"); + }); + + test('surfaces missing key error from array shape inside union', function () { + expect(fn() => testDeepUnionError(['id' => 10])) + ->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'"); + }); + + test('surfaces deep object shape error inside union using anonymous class', function () { + $user = new class { + public int $id = 10; + public object $profile; + + public function __construct() { + $this->profile = new class { + public string $name = ''; + }; + } + }; + + expect(fn() => testDeepObjectShapeUnion($user)) + ->toThrow(\TypeError::class, "Argument \$user->profile->name must be of type non-empty-string"); + }); + + test('surfaces missing property error on object shape inside union using anonymous class', function () { + $obj = new class { + public int $id = 10; + }; + + expect(fn() => testMissingObjectPropertyUnion($obj)) + ->toThrow(\TypeError::class, "Argument \$data is missing required property 'role'"); + }); + + test('surfaces uninitialized property error on object shape inside union using anonymous class', function () { + $obj = new class { + public string $name; + }; + + expect(fn() => testUninitializedObjectPropertyUnion($obj)) + ->toThrow(\TypeError::class, "Argument \$data property 'name' is uninitialized"); + }); + + test('surfaces deep error in nested discriminated union shape', function () { + $payload = [ + 'type' => 'A', + 'data' => ['score' => -5], + ]; + + expect(fn() => testDiscriminatedUnionDeepError($payload)) + ->toThrow(\TypeError::class, "['score'] must be of type positive-int"); + }); + + test('surfaces deep error on class property with union shape', function () { + $fixture = new PropertyUnionFixture(); + + expect(fn() => $fixture->settings = ['config' => ['enabled' => 'not_a_bool']]) + ->toThrow(\TypeError::class, "Property PropertyUnionFixture::\$settings['config']['enabled'] must be of type bool"); + }); + + test('falls back gracefully to generic union error when no deep branch matches structure', function () { + expect(fn() => testDeepUnionError('string_instead_of_array')) + ->toThrow(\TypeError::class, "must be of type (array{id: int, tags: list<(string | int)>} | null)"); + }); +}); \ No newline at end of file