diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index 827e274..0dcbce3 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal\Diagnostic; +use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Util\Config; /** @@ -77,6 +78,19 @@ public static function formatGivenValue(mixed $value, bool $isSensitive = false) return $value ? 'bool (true)' : 'bool (false)'; } + if (\is_object($value)) { + $className = $value::class; + $bindings = TemplateManager::getBoundTemplatesForInstance($value); + + if ($bindings !== []) { + $typeStrings = array_map(static fn ($node): string => (string) $node, array_values($bindings)); + + return $className . '<' . implode(', ', $typeStrings) . '>'; + } + + return get_debug_type($value); + } + return get_debug_type($value); } } diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index de35ad3..c23fa3d 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -4,6 +4,8 @@ namespace TypePHP\Internal\Generics; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -11,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; @@ -919,10 +922,21 @@ private static function bindSingleTemplateArgument( bool $forceBind ): ?ErrorMessage { if ($expectedTypeNode instanceof IdentifierTypeNode) { - $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($expectedTypeNode->name); - $isRealType = self::isRealTypeSymbol($expectedTypeNode->name); + if (str_contains($expectedTypeNode->name, '::')) { + $targetClass = explode('::', $expectedTypeNode->name, 2)[0]; + if (! self::isRealTypeSymbol($targetClass)) { + return null; + } + } else { + $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($expectedTypeNode->name); + $isRealType = self::isRealTypeSymbol($expectedTypeNode->name); - if (! $isBuiltIn && ! $isRealType) { + if (! $isBuiltIn && ! $isRealType) { + return null; + } + } + } elseif ($expectedTypeNode instanceof ConstTypeNode && $expectedTypeNode->constExpr instanceof ConstFetchNode && $expectedTypeNode->constExpr->className !== '') { + if (! self::isRealTypeSymbol($expectedTypeNode->constExpr->className)) { return null; } } @@ -1148,14 +1162,25 @@ private static function collectInheritedGenericTagBindings( $resolved = self::resolveTypeNodeAst($typeArg, $hierClass); if ($resolved instanceof IdentifierTypeNode) { - $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); - $isRealType = self::isRealTypeSymbol($resolved->name); - - if (! $isBuiltIn && ! $isRealType) { - continue; + if (str_contains($resolved->name, '::')) { + $targetClass = explode('::', $resolved->name, 2)[0]; + if (! self::isRealTypeSymbol($targetClass)) { + continue; + } + } else { + $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); + $isRealType = self::isRealTypeSymbol($resolved->name); + + if (! $isBuiltIn && ! $isRealType) { + continue; + } + + if (isset($declaredTemplateNames[$resolved->name])) { + continue; + } } - - if (isset($declaredTemplateNames[$resolved->name])) { + } elseif ($resolved instanceof ConstTypeNode && $resolved->constExpr instanceof ConstFetchNode && $resolved->constExpr->className !== '') { + if (! self::isRealTypeSymbol($resolved->constExpr->className)) { continue; } } @@ -1627,6 +1652,21 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new IdentifierTypeNode($parent !== false ? $parent->getName() : 'parent'); } + if (str_contains($n->name, '::')) { + [$className, $memberName] = explode('::', $n->name, 2); + $lowerClass = strtolower($className); + if ($lowerClass === 'self' || $lowerClass === 'static' || $lowerClass === '$this') { + $resolvedClass = $ref->getName(); + } elseif ($lowerClass === 'parent') { + $parent = $ref->getParentClass(); + $resolvedClass = $parent !== false ? $parent->getName() : 'parent'; + } else { + $resolvedClass = SpecialTypeResolver::resolveFqcn($className, $ref); + } + + return new IdentifierTypeNode($resolvedClass . '::' . $memberName); + } + return new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->name, $ref)); } @@ -1634,6 +1674,26 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new IdentifierTypeNode($ref->getName()); } + if ($n instanceof ConstTypeNode) { + if ($n->constExpr instanceof ConstFetchNode && $n->constExpr->className !== '') { + $className = $n->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'static' || $lowerClassName === '$this') { + $resolvedClass = $ref->getName(); + } elseif ($lowerClassName === 'parent') { + $parent = $ref->getParentClass(); + $resolvedClass = $parent !== false ? $parent->getName() : 'parent'; + } else { + $resolvedClass = SpecialTypeResolver::resolveFqcn($className, $ref); + } + + return new ConstTypeNode(new ConstFetchNode($resolvedClass, $n->constExpr->name)); + } + + return $n; + } + if ($n instanceof GenericTypeNode) { $lower = strtolower($n->type->name); $baseName = match ($lower) { @@ -1680,8 +1740,58 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): if ($n instanceof ArrayShapeNode) { $items = []; foreach ($n->items as $item) { + $keyName = $item->keyName; + + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { + $className = $keyName->className; + $constName = $keyName->name; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'static' || $lowerClassName === '$this') { + $resolvedClass = $ref->getName(); + } elseif ($lowerClassName === 'parent') { + $parent = $ref->getParentClass(); + $resolvedClass = $parent !== false ? $parent->getName() : 'parent'; + } else { + $resolvedClass = SpecialTypeResolver::resolveFqcn($className, $ref); + } + + $resolvedKeyNode = SpecialTypeResolver::resolveConstantKeyValue($resolvedClass, $constName); + $keyName = $resolvedKeyNode ?? new ConstFetchNode($resolvedClass, $constName); + } elseif ($keyName instanceof IdentifierTypeNode && str_contains($keyName->name, '::')) { + [$className, $constName] = explode('::', $keyName->name, 2); + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'static' || $lowerClassName === '$this') { + $resolvedClass = $ref->getName(); + } elseif ($lowerClassName === 'parent') { + $parent = $ref->getParentClass(); + $resolvedClass = $parent !== false ? $parent->getName() : 'parent'; + } else { + $resolvedClass = SpecialTypeResolver::resolveFqcn($className, $ref); + } + + $resolvedKeyNode = SpecialTypeResolver::resolveConstantKeyValue($resolvedClass, $constName); + $keyName = $resolvedKeyNode ?? new IdentifierTypeNode($resolvedClass . '::' . $constName); + } elseif ($keyName instanceof ConstExprStringNode && str_contains($keyName->value, '::')) { + [$className, $constName] = explode('::', $keyName->value, 2); + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'static' || $lowerClassName === '$this') { + $resolvedClass = $ref->getName(); + } elseif ($lowerClassName === 'parent') { + $parent = $ref->getParentClass(); + $resolvedClass = $parent !== false ? $parent->getName() : 'parent'; + } else { + $resolvedClass = SpecialTypeResolver::resolveFqcn($className, $ref); + } + + $resolvedKeyNode = SpecialTypeResolver::resolveConstantKeyValue($resolvedClass, $constName); + $keyName = $resolvedKeyNode ?? new ConstExprStringNode($resolvedClass . '::' . $constName, $keyName->quoteType); + } + $items[] = new ArrayShapeItemNode( - $item->keyName, + $keyName, $item->optional, self::resolveTypeNodeAst($item->valueType, $ref) ); diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 4449ddf..78ab827 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -341,6 +341,18 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode return clone $node; } + if (str_contains($node->name, '::')) { + [$className, $member] = explode('::', $node->name, 2); + $lowerClass = strtolower($className); + if (\in_array($lowerClass, ['self', 'static', 'parent', '$this'], strict: true)) { + return clone $node; + } + + $resolvedClass = self::resolveFqcnForFile($className, $file); + + return $resolvedClass !== $className ? new IdentifierTypeNode($resolvedClass . '::' . $member) : clone $node; + } + $fqcn = self::resolveFqcnForFile($node->name, $file); return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : clone $node; @@ -542,6 +554,25 @@ private static function resolveIdentifier( } } + if (str_contains($node->name, '::')) { + [$className, $member] = explode('::', $node->name, 2); + $lowerClass = strtolower($className); + if (($lowerClass === 'static' || $lowerClass === '$this') && $thisObj !== null) { + $resolvedClass = $thisObj::class; + } elseif ($lowerClass === 'static' && \is_string($context) && ($callingClass = strstr($context, '::', true)) !== false && self::symbolExists($callingClass)) { + $resolvedClass = $callingClass; + } elseif (($lowerClass === 'self' || $lowerClass === 'static' || $lowerClass === '$this') && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClass === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } + + return $resolvedClass !== $className ? new IdentifierTypeNode($resolvedClass . '::' . $member) : $node; + } + $fqcn = self::resolveFqcn($node->name, $ref); return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : $node; @@ -556,7 +587,7 @@ private static function resolveConstType(ConstTypeNode $node, ?string $declaring $className = $node->constExpr->className; $lowerClassName = strtolower($className); - if ($lowerClassName === 'self' && $declaringClass !== null) { + if (($lowerClassName === 'self' || $lowerClassName === 'static' || $lowerClassName === '$this') && $declaringClass !== null) { $resolvedClass = $declaringClass; } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { $parentClass = get_parent_class($declaringClass); @@ -954,7 +985,7 @@ private static function resolveConstantOffsetValue(string $fqcn, string $constNa return null; } - private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null + public static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null { $val = self::getClassConstant($fqcn, $constName); if (\is_string($val)) { diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 7f7043b..eb5d3e7 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -306,12 +306,12 @@ public static function setupScope(string $function, array $vars, object|string|n ); if ($err !== null) { + TemplateManager::popCallFrame($effectiveFunction); + if (IgnoreManager::isCallerIgnored()) { return null; } - TemplateManager::popCallFrame($effectiveFunction); - $handled = ViolationCollector::handle($err, 'parameter', null, function: $effectiveFunction); if ($handled instanceof ErrorMessage) { return $handled; diff --git a/tests/Fixtures/Commands/Actions/ClassLevelTraitLocationConsumer.php b/tests/Fixtures/Commands/Actions/ClassLevelTraitLocationConsumer.php new file mode 100644 index 0000000..963983b --- /dev/null +++ b/tests/Fixtures/Commands/Actions/ClassLevelTraitLocationConsumer.php @@ -0,0 +1,16 @@ +}> + */ +final class ClassLevelTraitLocationConsumer +{ + use HasLocationOptions; +} diff --git a/tests/Fixtures/Commands/Actions/ExecuteLocationsCommand.php b/tests/Fixtures/Commands/Actions/ExecuteLocationsCommand.php new file mode 100644 index 0000000..21b0602 --- /dev/null +++ b/tests/Fixtures/Commands/Actions/ExecuteLocationsCommand.php @@ -0,0 +1,19 @@ +}> + */ +final class ExecuteLocationsCommand implements CommandInterface +{ + public function execute(array $options): array + { + return $options; + } +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationByStatus.php b/tests/Fixtures/Commands/Actions/GetLocationByStatus.php new file mode 100644 index 0000000..c51ebb3 --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationByStatus.php @@ -0,0 +1,15 @@ + + */ +final class GetLocationByStatus extends Command +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationDirectValue.php b/tests/Fixtures/Commands/Actions/GetLocationDirectValue.php new file mode 100644 index 0000000..17dc404 --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationDirectValue.php @@ -0,0 +1,15 @@ + + */ +final class GetLocationDirectValue extends ValueCommand +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationMapKeyValue.php b/tests/Fixtures/Commands/Actions/GetLocationMapKeyValue.php new file mode 100644 index 0000000..3752eaf --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationMapKeyValue.php @@ -0,0 +1,15 @@ +> + */ +final class GetLocationMapKeyValue extends ValueCommand +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationMaskValue.php b/tests/Fixtures/Commands/Actions/GetLocationMaskValue.php new file mode 100644 index 0000000..0e700bb --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationMaskValue.php @@ -0,0 +1,15 @@ +> + */ +final class GetLocationMaskValue extends ValueCommand +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocations.php b/tests/Fixtures/Commands/Actions/GetLocations.php new file mode 100644 index 0000000..c02182e --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocations.php @@ -0,0 +1,15 @@ +}> + */ +final class GetLocations extends Command +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationsFqcn.php b/tests/Fixtures/Commands/Actions/GetLocationsFqcn.php new file mode 100644 index 0000000..07aa537 --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationsFqcn.php @@ -0,0 +1,14 @@ +}> + */ +final class GetLocationsFqcn extends Command +{ +} diff --git a/tests/Fixtures/Commands/Actions/GetLocationsWithConstantKey.php b/tests/Fixtures/Commands/Actions/GetLocationsWithConstantKey.php new file mode 100644 index 0000000..71aa74a --- /dev/null +++ b/tests/Fixtures/Commands/Actions/GetLocationsWithConstantKey.php @@ -0,0 +1,15 @@ +}> + */ +final class GetLocationsWithConstantKey extends Command +{ +} diff --git a/tests/Fixtures/Commands/Actions/InlineTraitLocationConsumer.php b/tests/Fixtures/Commands/Actions/InlineTraitLocationConsumer.php new file mode 100644 index 0000000..5ff63c6 --- /dev/null +++ b/tests/Fixtures/Commands/Actions/InlineTraitLocationConsumer.php @@ -0,0 +1,16 @@ +}> + */ + use HasLocationOptions; +} diff --git a/tests/Fixtures/Commands/Core/Command.php b/tests/Fixtures/Commands/Core/Command.php new file mode 100644 index 0000000..da68f57 --- /dev/null +++ b/tests/Fixtures/Commands/Core/Command.php @@ -0,0 +1,32 @@ + + */ +abstract class Command +{ + /** + * @var TOptions + */ + protected array $options; + + /** + * @param TOptions $options + */ + public function __construct(array $options) + { + $this->options = $options; + } + + /** + * @return TOptions + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/tests/Fixtures/Commands/Core/CommandInterface.php b/tests/Fixtures/Commands/Core/CommandInterface.php new file mode 100644 index 0000000..6cddb71 --- /dev/null +++ b/tests/Fixtures/Commands/Core/CommandInterface.php @@ -0,0 +1,18 @@ + + */ +interface CommandInterface +{ + /** + * @param TOptions $options + * + * @return TOptions + */ + public function execute(array $options): array; +} diff --git a/tests/Fixtures/Commands/Core/HasLocationOptions.php b/tests/Fixtures/Commands/Core/HasLocationOptions.php new file mode 100644 index 0000000..998da89 --- /dev/null +++ b/tests/Fixtures/Commands/Core/HasLocationOptions.php @@ -0,0 +1,28 @@ + + */ +trait HasLocationOptions +{ + /** + * @var TOptions|null + */ + public ?array $savedOptions = null; + + /** + * @param TOptions $options + * + * @return TOptions + */ + public function setLocationOptions(array $options): array + { + $this->savedOptions = $options; + + return $options; + } +} diff --git a/tests/Fixtures/Commands/Core/ValueCommand.php b/tests/Fixtures/Commands/Core/ValueCommand.php new file mode 100644 index 0000000..c1d114f --- /dev/null +++ b/tests/Fixtures/Commands/Core/ValueCommand.php @@ -0,0 +1,18 @@ + self::STATUS_ONLINE, + 'offline' => self::STATUS_OFFLINE, + ]; +} diff --git a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php index 7641b14..08f14d2 100644 --- a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php +++ b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php @@ -205,7 +205,10 @@ final class FBoundImpostor extends FBoundBase $impostor = new FBoundImpostor(); expect(fn () => $impostor->copy()) - ->toThrow(TypeError::class, 'Return value must be of type TypePHP\Tests\TypeChecking\Generics\FBoundDerived, TypePHP\Tests\TypeChecking\Generics\FBoundImpostor returned') + ->toThrow( + TypeError::class, + 'Return value must be of type ' . FBoundDerived::class . ', ' . FBoundImpostor::class . '<' . FBoundDerived::class . '> returned' + ) ; }); diff --git a/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php b/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php new file mode 100644 index 0000000..714056b --- /dev/null +++ b/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php @@ -0,0 +1,129 @@ + | DiagnosticInnerBox + */ +class DiagnosticOuterUnionContainer +{ + /** + * @param T $box + */ + public function __construct(public mixed $box) + { + } +} + +/** + * Parameter with DocBlock scalar refinement and native mixed type + * + * @param positive-int $num + */ +function acceptPositiveIntParam(mixed $num): void +{ +} + +/** + * Parameter with DocBlock scalar union and native mixed type + * + * @param non-empty-string|positive-int $val + */ +function acceptScalarUnionParam(mixed $val): void +{ +} + +describe('Preserve Reified Nested Generics in TypeFormatter::formatGivenValue()', function () { + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + test('renders reified generic type in given clause on union bound failure (Reproduction)', function () { + $floatBox = new DiagnosticInnerBox(3.14); + + expect(fn () => new DiagnosticOuterUnionContainer($floatBox)) + ->toThrow( + TypeError::class, + DiagnosticInnerBox::class . ' given' + ) + ; + }); + + test('renders reified generic type in given clause on scalar parameter failure', function () { + $stringBox = new DiagnosticInnerBox('hello'); + + expect(fn () => acceptPositiveIntParam($stringBox)) + ->toThrow( + TypeError::class, + DiagnosticInnerBox::class . ' given' + ) + ; + }); + + test('renders reified generic type in given clause on scalar union failure', function () { + $dogBox = new DiagnosticInnerBox(new Dog()); + + expect(fn () => acceptScalarUnionParam($dogBox)) + ->toThrow( + TypeError::class, + DiagnosticInnerBox::class . '<' . Dog::class . '> given' + ) + ; + }); + + test('falls back to bare class name when redaction is enabled', function () { + Config::set(['redact_values' => true]); + + $floatBox = new DiagnosticInnerBox(3.14); + + expect(fn () => acceptPositiveIntParam($floatBox)) + ->toThrow( + TypeError::class, + DiagnosticInnerBox::class . ' given' + ) + ; + + expect(fn () => new DiagnosticOuterUnionContainer($floatBox)) + ->toThrow( + TypeError::class, + DiagnosticInnerBox::class . ' given' + ) + ; + }); + + test('TypeFormatter::formatGivenValue directly formats reified generics and respects redaction', function () { + $floatBox = new DiagnosticInnerBox(3.14); + + expect(TypeFormatter::formatGivenValue($floatBox)) + ->toBe(DiagnosticInnerBox::class . '') + ; + + expect(TypeFormatter::formatGivenValue($floatBox, isSensitive: true)) + ->toBe(DiagnosticInnerBox::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/SubclassImportedConstantInInheritedGenericTest.php b/tests/TypeChecking/Generics/SubclassImportedConstantInInheritedGenericTest.php new file mode 100644 index 0000000..955aa7d --- /dev/null +++ b/tests/TypeChecking/Generics/SubclassImportedConstantInInheritedGenericTest.php @@ -0,0 +1,123 @@ + [Location::STATUS_ONLINE]]); + expect($cmd->getOptions())->toBe(['statuses' => [1]]); + }); + + test('accepts valid values matching wildcard constant when class is imported in subclass', function () { + $cmdOnline = new GetLocations(['statuses' => [Location::STATUS_ONLINE]]); + expect($cmdOnline->getOptions())->toBe(['statuses' => [1]]); + + $cmdOffline = new GetLocations(['statuses' => [Location::STATUS_OFFLINE]]); + expect($cmdOffline->getOptions())->toBe(['statuses' => [0]]); + }); + + test('rejects values that violate the wildcard constant constraint and displays the resolved FQCN in error', function () { + expect(fn () => new GetLocations(['statuses' => [999]])) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + + test('resolves specific imported class constant in inherited array shape', function () { + $cmd = new GetLocationByStatus(['status' => 1]); + expect($cmd->getOptions())->toBe(['status' => 1]); + + expect(fn () => new GetLocationByStatus(['status' => 0])) + ->toThrow(TypeError::class, 'must be literal ' . Location::class . '::STATUS_ONLINE') + ; + }); + + test('resolves imported class constant passed directly as generic template argument', function () { + $cmd = new GetLocationDirectValue(1); + expect($cmd->value)->toBe(1); + + expect(fn () => new GetLocationDirectValue(999)) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + + test('resolves imported class constant used as an array shape key in @extends', function () { + $cmd = new GetLocationsWithConstantKey(['statuses' => [Location::STATUS_ONLINE]]); + expect($cmd->getOptions())->toBe(['statuses' => [1]]); + + expect(fn () => new GetLocationsWithConstantKey(['statuses' => [999]])) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + + test('resolves int-mask-of with imported constant in @extends', function () { + $cmd = new GetLocationMaskValue(Location::STATUS_ONLINE); + expect($cmd->value)->toBe(1); + + expect(fn () => new GetLocationMaskValue(4)) + ->toThrow(TypeError::class, 'must be a valid bitmask combination') + ; + }); + + test('resolves key-of with imported constant map in @extends', function () { + $cmd = new GetLocationMapKeyValue('online'); + expect($cmd->value)->toBe('online'); + + expect(fn () => new GetLocationMapKeyValue('unknown_key')) + ->toThrow(TypeError::class, 'must be a key of ' . Location::class . '::STATUS_MAP') + ; + }); + }); + + describe('2. Interface Implementation (@implements)', function () { + test('resolves imported class constant wildcard in @implements tag', function () { + $executor = new ExecuteLocationsCommand(); + + $result = $executor->execute(['statuses' => [Location::STATUS_ONLINE, Location::STATUS_OFFLINE]]); + expect($result)->toBe(['statuses' => [1, 0]]); + + expect(fn () => $executor->execute(['statuses' => [999]])) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + }); + + describe('3. Trait Usage (@use)', function () { + test('resolves imported class constant wildcard in inline trait use statement (/** @use */ use Trait;)', function () { + $consumer = new InlineTraitLocationConsumer(); + + $result = $consumer->setLocationOptions(['statuses' => [Location::STATUS_ONLINE]]); + expect($result)->toBe(['statuses' => [1]]); + + expect(fn () => $consumer->setLocationOptions(['statuses' => [999]])) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + + test('resolves imported class constant wildcard in class-level trait use docblock', function () { + $consumer = new ClassLevelTraitLocationConsumer(); + + $result = $consumer->setLocationOptions(['statuses' => [Location::STATUS_OFFLINE]]); + expect($result)->toBe(['statuses' => [0]]); + + expect(fn () => $consumer->setLocationOptions(['statuses' => [999]])) + ->toThrow(TypeError::class, Location::class . '::STATUS_*') + ; + }); + }); +});