From e0b6e3d5e096f3dfd4770e9e3313fd7453f462ee Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 13 Aug 2026 00:02:29 +0800 Subject: [PATCH 1/3] Add WildcardConstantFixture and tests for version mode validation for baseline tdd --- .../Types/WildcardConstantFixture.php | 20 +++++++++++++++++++ .../Scalars/ExtendedScalarTypesTest.php | 16 +++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/Fixtures/Types/WildcardConstantFixture.php diff --git a/tests/Fixtures/Types/WildcardConstantFixture.php b/tests/Fixtures/Types/WildcardConstantFixture.php new file mode 100644 index 0000000..32718f0 --- /dev/null +++ b/tests/Fixtures/Types/WildcardConstantFixture.php @@ -0,0 +1,20 @@ +toBe('all'); + expect(WildcardConstantFixture::setVersionMode('blue-green'))->toBe('blue-green'); + expect(WildcardConstantFixture::setVersionMode('internal-mode'))->toBe('internal-mode'); + }); + + test('throws TypeError on value not matching wildcard constant pattern', function () { + expect(fn () => WildcardConstantFixture::setVersionMode('invalid_mode')) + ->toThrow(TypeError::class, "must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*") + ; + }); +}); \ No newline at end of file From 4905f83465b86d7c5f6ed3b8fabf77351a1d2330 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 13 Aug 2026 00:05:35 +0800 Subject: [PATCH 2/3] Add support for wildcard class constant patterns in ConstValidator and documentation --- .../supported-types/primitives-and-scalars.md | 36 +++++++++++ src/Validator/ConstValidator.php | 62 +++++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md index 81b66e8..29ef5fb 100644 --- a/docs/supported-types/primitives-and-scalars.md +++ b/docs/supported-types/primitives-and-scalars.md @@ -74,6 +74,42 @@ To prevent rounding artifacts from causing unexpected type failures, TypePHP eva --- +## Wildcard Class Constant Patterns (`Class::PREFIX_*`) + +TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard class constant patterns (`Class::PREFIX_*` or `self::PREFIX_*`). + +TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant values: + +```php +class MigrationCollectionLoader +{ + public const VERSION_SELECTION_ALL = 'all'; + public const VERSION_SELECTION_BLUE_GREEN = 'blue-green'; + private const VERSION_SELECTION_INTERNAL = 'internal-mode'; + + /** + * @param self::VERSION_SELECTION_* $mode + */ + public function collectAllForVersion(string $mode): string + { + return $mode; + } +} + +$loader = new MigrationCollectionLoader(); + +// Valid Calls (Matches public and private VERSION_SELECTION_* constant values) +$loader->collectAllForVersion('all'); +$loader->collectAllForVersion('blue-green'); +$loader->collectAllForVersion('internal-mode'); + +// Invalid Call ('invalid_mode' does not match any VERSION_SELECTION_* constant) +$loader->collectAllForVersion('invalid_mode'); +// Throws: TypeError: Argument $mode must be a valid constant matching MigrationCollectionLoader::VERSION_SELECTION_* +``` + +--- + ## Integer Refinements and Ranges TypePHP enforces exact value constraints and bounds on integer parameters: diff --git a/src/Validator/ConstValidator.php b/src/Validator/ConstValidator.php index a794916..abdf46e 100644 --- a/src/Validator/ConstValidator.php +++ b/src/Validator/ConstValidator.php @@ -18,10 +18,15 @@ use TypePHP\Internal\TypeFormatter; /** - * @internal Validates literal values, class constants, and PHP 8.1 Enum cases against ConstTypeNode ASTs. + * @internal Validates literal values, class constants, and wildcard constant patterns (Class::PREFIX_*) against ConstTypeNode ASTs. */ final class ConstValidator implements TypeValidatorInterface { + /** + * @var array> + */ + private static array $wildcardConstantCache = []; + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { /** @var ConstTypeNode $constTypeNode */ @@ -41,9 +46,24 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } elseif ($constExpr instanceof ConstExprFloatNode) { $expected = (float) $constExpr->value; } elseif ($constExpr instanceof ConstFetchNode) { - $fqcnConstant = $constExpr->className !== '' - ? $constExpr->className . '::' . $constExpr->name - : $constExpr->name; + $className = $constExpr->className; + $pattern = $constExpr->name; + + if (str_contains($pattern, '*')) { + $allowedValues = self::resolveWildcardConstantValues($className, $pattern); + + if (! \in_array($value, $allowedValues, true)) { + $fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern; + + return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + + $fqcnConstant = $className !== '' + ? $className . '::' . $pattern + : $pattern; if (\defined($fqcnConstant)) { $expected = \constant($fqcnConstant); @@ -69,4 +89,36 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} + + /** + * Resolves and caches all values of class constants matching a wildcard pattern (e.g. PREFIX_*). + * + * @return array + */ + private static function resolveWildcardConstantValues(string $className, string $pattern): array + { + $cacheKey = $className . '::' . $pattern; + if (isset(self::$wildcardConstantCache[$cacheKey])) { + return self::$wildcardConstantCache[$cacheKey]; + } + + $values = []; + + if ($className !== '' && (class_exists($className) || interface_exists($className))) { + try { + $refClass = new \ReflectionClass($className); + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + + foreach ($refClass->getConstants() as $cName => $cValue) { + if (preg_match($regex, $cName) === 1) { + $values[] = $cValue; + } + } + } catch (\ReflectionException $e) { + // Silently ignore reflection errors + } + } + + return self::$wildcardConstantCache[$cacheKey] = $values; + } +} \ No newline at end of file From 3d556f9bb36d43cd0a9d71fb26a64b3d7fba33a8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 13 Aug 2026 00:18:33 +0800 Subject: [PATCH 3/3] Enhance wildcard constant pattern validation for enums and update documentation --- .../supported-types/primitives-and-scalars.md | 39 ++++++++++++++----- src/Validator/ArrayShapeValidator.php | 2 +- src/Validator/ConstValidator.php | 2 +- tests/Fixtures/Types/EnumWildcard.php | 24 ++++++++++++ tests/Fixtures/Types/GlobalTypes.php | 2 +- .../Types/WildcardConstantFixture.php | 2 +- .../ArraysAndShapes/ArrayAndListTypesTest.php | 4 +- .../UnionErrorBubblingTest.php | 3 +- .../GenericTemplateBoundsStressTest.php | 3 +- .../Scalars/ExtendedScalarTypesTest.php | 21 +++++++++- 10 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 tests/Fixtures/Types/EnumWildcard.php diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md index 29ef5fb..73c753a 100644 --- a/docs/supported-types/primitives-and-scalars.md +++ b/docs/supported-types/primitives-and-scalars.md @@ -74,11 +74,12 @@ To prevent rounding artifacts from causing unexpected type failures, TypePHP eva --- -## Wildcard Class Constant Patterns (`Class::PREFIX_*`) +## Wildcard Constant Patterns (`Class::PREFIX_*` & `StatusEnum::*`) -TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard class constant patterns (`Class::PREFIX_*` or `self::PREFIX_*`). +TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard constant patterns (`Class::PREFIX_*` or `StatusEnum::*`). -TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant values: +* **Class Constants (`Class::PREFIX_*`):** TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant scalar values. +* **Enums (`StatusEnum::*` or `StatusEnum::ACT*`):** Matches incoming **Enum case objects** against the wildcard case pattern. ```php class MigrationCollectionLoader @@ -96,16 +97,36 @@ class MigrationCollectionLoader } } +enum StatusEnum: string +{ + case Active = 'active'; + case Inactive = 'inactive'; + case Pending = 'pending'; +} + +class EnumProcessor +{ + /** + * @param StatusEnum::ACT* $status + */ + public static function processActive(StatusEnum $status): StatusEnum + { + return $status; + } +} + $loader = new MigrationCollectionLoader(); -// Valid Calls (Matches public and private VERSION_SELECTION_* constant values) +// Valid Class Constant Calls (Matches public and private VERSION_SELECTION_* constant values) $loader->collectAllForVersion('all'); -$loader->collectAllForVersion('blue-green'); $loader->collectAllForVersion('internal-mode'); -// Invalid Call ('invalid_mode' does not match any VERSION_SELECTION_* constant) -$loader->collectAllForVersion('invalid_mode'); -// Throws: TypeError: Argument $mode must be a valid constant matching MigrationCollectionLoader::VERSION_SELECTION_* +// Valid Enum Wildcard Call (Matches StatusEnum::ACT* -> StatusEnum::Active) +EnumProcessor::processActive(StatusEnum::Active); + +// Invalid Enum Wildcard Call (StatusEnum::Pending does not match StatusEnum::ACT*) +EnumProcessor::processActive(StatusEnum::Pending); +// Throws: TypeError: Argument $status must be a valid constant matching StatusEnum::ACT* ``` --- @@ -394,4 +415,4 @@ function badHaltExecution(): string badHaltExecution(); // Throws: TypeError: badHaltExecution(): Return value must be of type never -``` +``` \ No newline at end of file diff --git a/src/Validator/ArrayShapeValidator.php b/src/Validator/ArrayShapeValidator.php index 95f379a..db82e0b 100644 --- a/src/Validator/ArrayShapeValidator.php +++ b/src/Validator/ArrayShapeValidator.php @@ -91,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} \ No newline at end of file +} diff --git a/src/Validator/ConstValidator.php b/src/Validator/ConstValidator.php index abdf46e..b5c3506 100644 --- a/src/Validator/ConstValidator.php +++ b/src/Validator/ConstValidator.php @@ -121,4 +121,4 @@ private static function resolveWildcardConstantValues(string $className, string return self::$wildcardConstantCache[$cacheKey] = $values; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/EnumWildcard.php b/tests/Fixtures/Types/EnumWildcard.php new file mode 100644 index 0000000..265b219 --- /dev/null +++ b/tests/Fixtures/Types/EnumWildcard.php @@ -0,0 +1,24 @@ +, list} * @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list} + * * @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple * * @param LocalTupleAlias $payload @@ -153,7 +154,6 @@ function testReturnKeylessTuple(bool $valid): array return [[10, 20], 'bundle']; } - describe('Class Object Arrays (Dog[])', function () { test('accepts array of matching class instances', function () { expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2); @@ -379,4 +379,4 @@ function testReturnKeylessTuple(bool $valid): array ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int") ; }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php index de3fa58..98635b4 100644 --- a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -140,6 +140,7 @@ public function __construct() 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)'); + ->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)') + ; }); }); diff --git a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php index 16d64c5..449518c 100644 --- a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php +++ b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php @@ -226,6 +226,7 @@ function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed expect(testInferredOverridesDefault($dt, $dt))->toBe($dt); expect(fn () => testInferredOverridesDefault($dt, new stdClass())) - ->toThrow(TypeError::class, 'Return value'); + ->toThrow(TypeError::class, 'Return value') + ; }); }); diff --git a/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php index 9b5a51a..f2e4776 100644 --- a/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php +++ b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use TypePHP\Tests\Fixtures\Types\EnumWildcard; +use TypePHP\Tests\Fixtures\Types\StatusEnum; use TypePHP\Tests\Fixtures\Types\WildcardConstantFixture; /** @@ -152,7 +154,22 @@ function testLiteralUnionsParam(string $status, int $code): bool test('throws TypeError on value not matching wildcard constant pattern', function () { expect(fn () => WildcardConstantFixture::setVersionMode('invalid_mode')) - ->toThrow(TypeError::class, "must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*") + ->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*') ; }); -}); \ No newline at end of file +}); + +describe('Enum Wildcard Pattern Validation (StatusEnum::*)', function () { + test('accepts Enum case objects matching wildcard patterns (StatusEnum::* & StatusEnum::ACT*)', function () { + expect(EnumWildcard::processEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active); + expect(EnumWildcard::processEnumCase(StatusEnum::Pending))->toBe(StatusEnum::Pending); + + expect(EnumWildcard::processPrefixEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active); + }); + + test('throws TypeError when Enum case object does not match prefix wildcard (e.g. Pending for StatusEnum::ACT*)', function () { + expect(fn () => EnumWildcard::processPrefixEnumCase(StatusEnum::Pending)) + ->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\StatusEnum::ACT*') + ; + }); +});