diff --git a/src/Internal/Checker/ConditionalChecker.php b/src/Internal/Checker/ConditionalChecker.php index 1a02814..d6ad145 100644 --- a/src/Internal/Checker/ConditionalChecker.php +++ b/src/Internal/Checker/ConditionalChecker.php @@ -23,6 +23,7 @@ use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use ReflectionClass; use Throwable; +use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -293,6 +294,28 @@ public static function resolveTemplateConditional( } $isTargetMatch = TemplateManager::checkVariance($subjectTypeNode, $node->targetType, GenericTypeNode::VARIANCE_COVARIANT); + + if (! $isTargetMatch && $node->targetType instanceof \PHPStan\PhpDocParser\Ast\Type\ConstTypeNode && $function !== '' && \count($vars) > 0) { + $contract = DocblockParser::parse($function); + foreach ($contract['types'] as $pName => $pType) { + $pTemplateName = ($pType instanceof IdentifierTypeNode) ? $pType->name : null; + $matchesSubject = false; + + if ($pTemplateName !== null && $node->subjectType instanceof IdentifierTypeNode) { + $matchesSubject = ($pTemplateName === $node->subjectType->name) + || (isset($boundTemplates[$pTemplateName]) && (string) $boundTemplates[$pTemplateName] === (string) $node->subjectType); + } + + if ($matchesSubject && (isset($vars[$pName]) || \array_key_exists($pName, $vars))) { + if ($registry->validate($vars[$pName], $node->targetType, 'condition') === null) { + $isTargetMatch = true; + + break; + } + } + } + } + if ($node->negated) { $isTargetMatch = ! $isTargetMatch; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index b484ad4..29af383 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -220,6 +220,35 @@ public static function checkParams( ); } + private static function refineInferredTypeFromBound(mixed $val, TypeNode $bound, TypeNode $defaultInferred): TypeNode + { + if ($bound instanceof \PHPStan\PhpDocParser\Ast\Type\ConstTypeNode) { + $expectedVal = match (true) { + $bound->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode => $bound->constExpr->value, + $bound->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode => (int) $bound->constExpr->value, + $bound->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprTrueNode => true, + $bound->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode => false, + $bound->constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode => null, + default => null, + }; + + if ($expectedVal !== null && $val === $expectedVal) { + return $bound; + } + } + + if ($bound instanceof UnionTypeNode) { + foreach ($bound->types as $member) { + $refined = self::refineInferredTypeFromBound($val, $member, $defaultInferred); + if ($refined !== $defaultInferred) { + return $refined; + } + } + } + + return $defaultInferred; + } + /** * Validates simple parameters when no generics/aliases are involved. * @@ -1442,6 +1471,8 @@ private static function bindInitialTemplate( if ($templateNode->bound !== null) { $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + $inferredType = self::refineInferredTypeFromBound($sampleVal, $resolvedBound, $inferredType); + $err = $registry->validate($sampleVal, $resolvedBound, $function . '(): Argument $' . $paramName . ' (template ' . $templateName . ')'); if ($err !== null) { return $err; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index a58b28f..285a062 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -408,6 +408,10 @@ private static function evaluateReturn( $resolvedType = $aliases[$resolvedType->name]; } + if (ConditionalChecker::containsConditional($resolvedType)) { + $resolvedType = ConditionalChecker::resolve($resolvedType, $vars, $boundTemplates, $registry, $function); + } + if (\count($boundTemplates) === 0 && \count($templates) > 0 && ! $hasConditionals && $thisObj === null) { $resolvedType = self::$unboundReturnCache[$function] ??= SpecialTypeResolver::resolve( TemplateSubstitutor::substitute($returnTypeNode, [], $templates), @@ -419,7 +423,9 @@ private static function evaluateReturn( $resolvedType = SpecialTypeResolver::resolve($resolvedType, $function, $thisObj); } - $resolvedType = ConditionalChecker::resolve($resolvedType, $vars, $boundTemplates, $registry, $function); + if (ConditionalChecker::containsConditional($resolvedType)) { + $resolvedType = ConditionalChecker::resolve($resolvedType, $vars, $boundTemplates, $registry, $function); + } if ($sig !== null) { self::$substitutedReturnCache[$function][$sig] = $resolvedType; diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 0408de0..fcbd4a4 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -5,12 +5,19 @@ namespace TypePHP\Internal\Generics; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; 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; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; +use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -1573,6 +1580,69 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } + if ($n instanceof ArrayShapeNode) { + $items = array_map(function ($item) use ($ref) { + return new ArrayShapeItemNode( + $item->keyName, + $item->optional, + self::resolveTypeNodeAst($item->valueType, $ref) + ); + }, $n->items); + + $unsealed = null; + if ($n->unsealedType !== null) { + $unsealedKey = $n->unsealedType->keyType !== null + ? self::resolveTypeNodeAst($n->unsealedType->keyType, $ref) + : null; + $unsealedVal = self::resolveTypeNodeAst($n->unsealedType->valueType, $ref); + $unsealed = new ArrayShapeUnsealedTypeNode($unsealedVal, $unsealedKey); + } + + if ($n->sealed) { + return ArrayShapeNode::createSealed($items, $n->kind); + } + + return ArrayShapeNode::createUnsealed($items, $unsealed, $n->kind); + } + + if ($n instanceof ObjectShapeNode) { + $items = array_map(function ($item) use ($ref) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolveTypeNodeAst($item->valueType, $ref) + ); + }, $n->items); + + return new ObjectShapeNode($items); + } + + if ($n instanceof CallableTypeNode) { + $params = array_map(function ($p) use ($ref) { + return new CallableTypeParameterNode( + self::resolveTypeNodeAst($p->type, $ref), + $p->isReference, + $p->isVariadic, + $p->parameterName, + $p->isOptional + ); + }, $n->parameters); + + return new CallableTypeNode( + $n->identifier, + $params, + self::resolveTypeNodeAst($n->returnType, $ref), + $n->templateTypes + ); + } + + if ($n instanceof OffsetAccessTypeNode) { + return new OffsetAccessTypeNode( + self::resolveTypeNodeAst($n->type, $ref), + self::resolveTypeNodeAst($n->offset, $ref) + ); + } + return $n; } } diff --git a/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php index 2e83a88..c0b8681 100644 --- a/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php +++ b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php @@ -214,7 +214,8 @@ function testGlobalConstantIntRange(int $n): int ; expect(fn () => testGlobalConstantIntRange(0)) - ->toThrow(TypeError::class, 'Argument $n must be >= 1, 0 given'); + ->toThrow(TypeError::class, 'Argument $n must be >= 1, 0 given') + ; }); }); }); diff --git a/tests/TypeChecking/Conditionals/LiteralTemplateConditionalReturnTest.php b/tests/TypeChecking/Conditionals/LiteralTemplateConditionalReturnTest.php new file mode 100644 index 0000000..d34ce9e --- /dev/null +++ b/tests/TypeChecking/Conditionals/LiteralTemplateConditionalReturnTest.php @@ -0,0 +1,241 @@ + : list) + */ +function testStringLiteralTemplateConditionalReturn(mixed $format, ?array $overrideReturn = null): array +{ + if ($overrideReturn !== null) { + return $overrideReturn; + } + + if ($format === 'raw') { + return [['name' => 'a']]; + } + + return [1, 2, 3]; +} + +/** + * @template TCode of 1|2 + * + * @param TCode $code + * @param mixed $val + * + * @return (TCode is 1 ? non-empty-string : positive-int) + */ +function testIntegerLiteralTemplateConditionalReturn(int $code, mixed $val): mixed +{ + return $val; +} + +/** + * @template T + * + * @param T $mode + * @param mixed $val + * + * @return (T is 'json' ? array{status: string} : non-empty-string) + */ +function testUnboundedTemplateLiteralConditionalReturn(mixed $mode, mixed $val): mixed +{ + return $val; +} + +/** + * @template T of 'raw'|'parsed' + * + * @param T $format + * @param mixed $val + * + * @return (T is not 'raw' ? list : list) + */ +function testNegatedLiteralTemplateConditionalReturn(string $format, mixed $val): mixed +{ + return $val; +} + +/** + * @template TFormat of 'raw'|'typed' + * + * @param TFormat $format + * @param (TFormat is 'raw' ? array{name: string} : int) $payload + */ +function testStringLiteralTemplateConditionalParam(mixed $format, mixed $payload): mixed +{ + return $payload; +} + +/** + * @template TCode of 1|2 + * + * @param TCode $code + * @param (TCode is 1 ? non-empty-string : positive-int) $payload + */ +function testIntegerLiteralTemplateConditionalParam(int $code, mixed $payload): mixed +{ + return $payload; +} + +/** + * @template T + * + * @param T $mode + * @param (T is 'json' ? array{status: string} : non-empty-string) $payload + */ +function testUnboundedTemplateLiteralConditionalParam(mixed $mode, mixed $payload): mixed +{ + return $payload; +} + +/** + * @template T of 'raw'|'parsed' + * + * @param T $format + * @param (T is not 'raw' ? list : list) $payload + */ +function testNegatedLiteralTemplateConditionalParam(string $format, mixed $payload): mixed +{ + return $payload; +} + +describe('Conditional Types Comparing Templates Against Literal Types (T is "literal")', function () { + describe('A. Conditional Return Types', function () { + test('selects if-branch return when argument matches target literal "raw"', function () { + expect(testStringLiteralTemplateConditionalReturn('raw'))->toBe([['name' => 'a']]); + }); + + test('selects else-branch return when argument matches non-target literal "typed"', function () { + expect(testStringLiteralTemplateConditionalReturn('typed'))->toBe([1, 2, 3]); + }); + + test('throws TypeError when if-branch return is selected but returns invalid shape for "raw"', function () { + expect(fn () => testStringLiteralTemplateConditionalReturn('raw', overrideReturn: [1, 2, 3])) + ->toThrow(TypeError::class, 'Return value[0] must be of type array, int (1) returned') + ; + }); + + test('throws TypeError when else-branch return is selected but returns invalid type for "typed"', function () { + expect(fn () => testStringLiteralTemplateConditionalReturn('typed', overrideReturn: [['name' => 'a']])) + ->toThrow(TypeError::class, 'Return value[0] must be of type int') + ; + }); + + test('selects return branch for integer literal template (TCode is 1)', function () { + expect(testIntegerLiteralTemplateConditionalReturn(1, 'valid_string'))->toBe('valid_string'); + expect(fn () => testIntegerLiteralTemplateConditionalReturn(1, '')) + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string') + ; + + expect(testIntegerLiteralTemplateConditionalReturn(2, 42))->toBe(42); + expect(fn () => testIntegerLiteralTemplateConditionalReturn(2, -5)) + ->toThrow(TypeError::class, 'Return value must be of type positive-int') + ; + }); + + test('selects return branch for unbounded template (T is "json")', function () { + $valid = ['status' => 'success']; + expect(testUnboundedTemplateLiteralConditionalReturn('json', $valid))->toBe($valid); + + expect(fn () => testUnboundedTemplateLiteralConditionalReturn('json', 'plain_string')) + ->toThrow(TypeError::class, 'Return value must be of type array') + ; + + expect(testUnboundedTemplateLiteralConditionalReturn('xml', 'valid_xml'))->toBe('valid_xml'); + expect(fn () => testUnboundedTemplateLiteralConditionalReturn('xml', '')) + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string') + ; + }); + + test('selects return branch for negated literal template (T is not "raw")', function () { + expect(testNegatedLiteralTemplateConditionalReturn('raw', ['a', 'b']))->toBe(['a', 'b']); + expect(fn () => testNegatedLiteralTemplateConditionalReturn('raw', [1, 2])) + ->toThrow(TypeError::class, 'Return value[0] must be of type string') + ; + + expect(testNegatedLiteralTemplateConditionalReturn('parsed', [10, 20]))->toBe([10, 20]); + expect(fn () => testNegatedLiteralTemplateConditionalReturn('parsed', ['not_int'])) + ->toThrow(TypeError::class, 'Return value[0] must be of type int') + ; + }); + }); + + describe('B. Conditional Parameter Types', function () { + test('enforces array shape payload parameter when format is "raw"', function () { + $validPayload = ['name' => 'Alice']; + expect(testStringLiteralTemplateConditionalParam('raw', $validPayload))->toBe($validPayload); + + expect(fn () => testStringLiteralTemplateConditionalParam('raw', 123)) + ->toThrow(TypeError::class, 'Argument $payload must be of type array, int (123) given') + ; + + expect(fn () => testStringLiteralTemplateConditionalParam('raw', [])) + ->toThrow(TypeError::class, "Argument \$payload is missing required key 'name'") + ; + }); + + test('enforces int payload parameter when format is "typed"', function () { + expect(testStringLiteralTemplateConditionalParam('typed', 42))->toBe(42); + + expect(fn () => testStringLiteralTemplateConditionalParam('typed', ['name' => 'Alice'])) + ->toThrow(TypeError::class, 'Argument $payload must be of type int') + ; + }); + + test('enforces non-empty-string vs positive-int for integer literal template parameter', function () { + expect(testIntegerLiteralTemplateConditionalParam(1, 'valid_code'))->toBe('valid_code'); + expect(fn () => testIntegerLiteralTemplateConditionalParam(1, '')) + ->toThrow(TypeError::class, 'Argument $payload must be of type non-empty-string') + ; + expect(fn () => testIntegerLiteralTemplateConditionalParam(1, 100)) + ->toThrow(TypeError::class, 'Argument $payload must be of type non-empty-string') + ; + + expect(testIntegerLiteralTemplateConditionalParam(2, 50))->toBe(50); + expect(fn () => testIntegerLiteralTemplateConditionalParam(2, -10)) + ->toThrow(TypeError::class, 'Argument $payload must be of type positive-int') + ; + expect(fn () => testIntegerLiteralTemplateConditionalParam(2, 'valid_code')) + ->toThrow(TypeError::class, 'Argument $payload must be of type positive-int') + ; + }); + + test('enforces conditional parameter on unbounded template (T is "json")', function () { + $jsonPayload = ['status' => 'ready']; + expect(testUnboundedTemplateLiteralConditionalParam('json', $jsonPayload))->toBe($jsonPayload); + + expect(fn () => testUnboundedTemplateLiteralConditionalParam('json', 'string_payload')) + ->toThrow(TypeError::class, 'Argument $payload must be of type array') + ; + + expect(testUnboundedTemplateLiteralConditionalParam('text', 'hello_world'))->toBe('hello_world'); + expect(fn () => testUnboundedTemplateLiteralConditionalParam('text', '')) + ->toThrow(TypeError::class, 'Argument $payload must be of type non-empty-string') + ; + }); + + test('enforces conditional parameter on negated literal template (T is not "raw")', function () { + expect(testNegatedLiteralTemplateConditionalParam('raw', ['x', 'y']))->toBe(['x', 'y']); + + expect(fn () => testNegatedLiteralTemplateConditionalParam('raw', [10, 20])) + ->toThrow(TypeError::class, 'Argument $payload[0] must be of type string') + ; + + expect(testNegatedLiteralTemplateConditionalParam('parsed', [10, 20]))->toBe([10, 20]); + + expect(fn () => testNegatedLiteralTemplateConditionalParam('parsed', ['x', 'y'])) + ->toThrow(TypeError::class, 'Argument $payload[0] must be of type int') + ; + }); + }); +}); diff --git a/tests/TypeChecking/Generics/InheritedShapeNamespaceResolutionTest.php b/tests/TypeChecking/Generics/InheritedShapeNamespaceResolutionTest.php new file mode 100644 index 0000000..dab4b21 --- /dev/null +++ b/tests/TypeChecking/Generics/InheritedShapeNamespaceResolutionTest.php @@ -0,0 +1,296 @@ + + */ +class InheritedArrayShapeUserBox extends InheritedShapeBaseBox +{ +} + +/** + * @extends InheritedShapeBaseBox + */ +class InheritedObjectShapeUserBox extends InheritedShapeBaseBox +{ +} + +/** + * @extends InheritedShapeBaseBox + */ +class InheritedCallableUserBox extends InheritedShapeBaseBox +{ +} + +/** + * Extends BaseBox with an iterable containing an unqualified class name + * + * @extends InheritedShapeBaseBox> + */ +class InheritedIterableUserBox extends InheritedShapeBaseBox +{ +} + +/** + * Extends BaseBox with a Traversable containing an unqualified class name + * + * @extends InheritedShapeBaseBox> + */ +class InheritedTraversableUserBox extends InheritedShapeBaseBox +{ + /** + * Consumes the Traversable so lazy iteration checks fire + * + * @param Traversable $items + * + * @return list + */ + public function consume(Traversable $items): array + { + $collected = []; + foreach ($items as $item) { + $collected[] = $item; + } + + return $collected; + } +} + +/** + * Extends BaseBox with a Generator containing an unqualified class name + * + * @extends InheritedShapeBaseBox> + */ +class InheritedGeneratorUserBox extends InheritedShapeBaseBox +{ + /** + * @return Generator + */ + public function stream(bool $valid = true): Generator + { + yield 0 => $valid ? new User('Alice') : new Car(); + } +} + +/** + * Extends BaseBox with an array shape holding a nested iterable of unqualified User + * + * @extends InheritedShapeBaseBox}> + */ +class InheritedShapeWithIterableUserBox extends InheritedShapeBaseBox +{ +} + +/** + * @template T + */ +interface InheritedShapeInterface +{ + /** + * @param T $data + */ + public function process(mixed $data): mixed; +} + +/** + * @implements InheritedShapeInterface + */ +class InheritedShapeInterfaceImplementation implements InheritedShapeInterface +{ + public function process(mixed $data): mixed + { + return $data; + } +} + +/** + * @template T + */ +trait InheritedShapeTrait +{ + /** + * @param T $record + */ + public function logRecord(mixed $record): mixed + { + return $record; + } +} + +class InheritedShapeInlineTraitConsumer +{ + /** + * @use InheritedShapeTrait + */ + use InheritedShapeTrait; +} + +/** + * @use InheritedShapeTrait + */ +class InheritedShapeClassLevelTraitConsumer +{ + use InheritedShapeTrait; +} + +describe('Inherited Generic Shapes & Iterables Namespace Resolution', function () { + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('Class Shapes & Callables (@extends)', function () { + test('resolves unqualified class names inside array shapes in @extends tag (reported bug)', function () { + $box = new InheritedArrayShapeUserBox(); + + $payload = ['user' => new User('Alice')]; + expect($box->put($payload))->toBe($payload); + + expect(fn () => $box->put(['user' => new Car()])) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside object shapes in @extends tag', function () { + $box = new InheritedObjectShapeUserBox(); + + $valid = (object)['user' => new User('Bob')]; + expect($box->put($valid))->toBe($valid); + + $invalid = (object)['user' => new Car()]; + expect(fn () => $box->put($invalid)) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside callables in @extends tag', function () { + $box = new InheritedCallableUserBox(); + + $cb = fn (User $u): User => $u; + $wrapped = $box->put($cb); + + expect($wrapped(new User('Charlie')))->toBeInstanceOf(User::class); + }); + }); + + describe('Iterables and Generators (@extends)', function () { + test('resolves unqualified class names inside iterable in @extends tag', function () { + $box = new InheritedIterableUserBox(); + + expect(TypePHP::getGenericType($box))->toBe('iterable'); + + $payload = [new User('Alice'), new User('Bob')]; + expect($box->put($payload))->toBe($payload); + + expect(fn () => $box->put([new Car()])) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside Traversable in @extends tag and validates on iteration', function () { + $box = new InheritedTraversableUserBox(); + + expect(TypePHP::getGenericType($box))->toBe('Traversable'); + + $iter = new ArrayIterator(['first' => new User('Alice')]); + expect($box->consume($iter))->toHaveCount(1); + + $badIter = new ArrayIterator(['first' => new Car()]); + expect(fn () => $box->consume($badIter)) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside Generator in @extends tag and validates on yield', function () { + $box = new InheritedGeneratorUserBox(); + + expect(TypePHP::getGenericType($box))->toBe('Generator'); + + $validGen = $box->stream(true); + expect($validGen->current())->toBeInstanceOf(User::class); + + $badGen = $box->stream(false); + expect(fn () => $badGen->current()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside array shape containing nested iterable', function () { + $box = new InheritedShapeWithIterableUserBox(); + + $valid = ['stream' => [new User('Alice'), new User('Bob')]]; + expect($box->put($valid))->toBe($valid); + + $invalid = ['stream' => [new Car()]]; + expect(fn () => $box->put($invalid)) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + }); + + describe('Interface Implementation (@implements)', function () { + test('resolves unqualified class names inside array shapes in @implements tag', function () { + $service = new InheritedShapeInterfaceImplementation(); + + $valid = ['user' => new User('Dave')]; + expect($service->process($valid))->toBe($valid); + + expect(fn () => $service->process(['user' => new Car()])) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + }); + + describe('Trait Usage (@use)', function () { + test('resolves unqualified class names inside array shapes in inline trait use statement', function () { + $consumer = new InheritedShapeInlineTraitConsumer(); + + $valid = ['user' => new User('Eve')]; + expect($consumer->logRecord($valid))->toBe($valid); + + expect(fn () => $consumer->logRecord(['user' => new Car()])) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + + test('resolves unqualified class names inside array shapes in class-level trait use docblock', function () { + $consumer = new InheritedShapeClassLevelTraitConsumer(); + + $valid = ['user' => new User('Frank')]; + expect($consumer->logRecord($valid))->toBe($valid); + + expect(fn () => $consumer->logRecord(['user' => new Car()])) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\User') + ; + }); + }); +});