From 090a1bca3135a8d732fe5e7cdade5d8482afe5b9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 3 Oct 2026 18:15:39 +0800 Subject: [PATCH 1/3] Add tests for global constant key-of, value-of, and int bounds --- src/Internal/Validator/GenericValidator.php | 910 +++++++++++------- .../GlobalConstantKeyOfValueOfTest.php | 211 ++++ 2 files changed, 749 insertions(+), 372 deletions(-) create mode 100644 tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 9773447..3b26cbc 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -15,6 +15,7 @@ use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use PHPStan\PhpDocParser\Ast\Node as PhpDocNode; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; @@ -62,8 +63,6 @@ final class GenericValidator implements TypeValidatorInterface private static array $enumValueCache = []; /** - * Cache for resolved int-mask-of masks: [$cacheKey] => array{0: int, 1: bool}. - * * @var array */ private static array $maskOfCache = []; @@ -90,179 +89,176 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali }; } - private function resolveConstantValue(string $fqcn, string $constName): mixed + private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { - $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + $targetType = $node->genericTypes[0] ?? null; + if ($targetType === null) { + return null; + } - if (! \array_key_exists($cacheKey, self::$constantCache)) { - $constValue = false; - if ($fqcn !== '') { - if (class_exists($fqcn) || interface_exists($fqcn)) { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - } - } - } else { - if (\defined($constName)) { - $constValue = \constant($constName); - } - } - self::$constantCache[$cacheKey] = $constValue; + if ($targetType instanceof GenericTypeNode && strtolower($targetType->type->name) === 'value-of') { + return $this->validateKeyOfNestedValueOf($value, $targetType, $context, $isSensitive); } - return self::$constantCache[$cacheKey]; + if ($targetType instanceof IdentifierTypeNode && ClassNameValidator::isValid($targetType->name) && enum_exists($targetType->name)) { + return $this->validateKeyOfEnum($value, $targetType->name, $context, $isSensitive); + } + + if ($targetType instanceof ArrayShapeNode) { + return $this->validateKeyOfArrayShape($value, $targetType, $context, $isSensitive); + } + + $target = $this->extractConstantTarget($targetType); + if ($target !== null) { + return $this->validateKeyOfConstant($value, $target[0], $target[1], $context, $isSensitive); + } + + return null; } - private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage + private function validateKeyOfNestedValueOf(mixed $value, GenericTypeNode $targetType, string $context, bool $isSensitive): ?ErrorMessage { - $targetType = $node->genericTypes[0] ?? null; + $innerTarget = $targetType->genericTypes[0] ?? null; + if (! $innerTarget instanceof ArrayShapeNode) { + return null; + } - if ($targetType instanceof GenericTypeNode && strtolower($targetType->type->name) === 'value-of') { - $innerTarget = $targetType->genericTypes[0] ?? null; - if ($innerTarget instanceof ArrayShapeNode) { - $validKeys = []; - foreach ($innerTarget->items as $item) { - if ($item->valueType instanceof ArrayShapeNode) { - foreach ($item->valueType->items as $subItem) { - $subKey = self::extractKeyFromItem($subItem); - if ($subKey !== null) { - $validKeys[] = $subKey; - } - } + $validKeys = []; + foreach ($innerTarget->items as $item) { + if ($item->valueType instanceof ArrayShapeNode) { + foreach ($item->valueType->items as $subItem) { + $subKey = self::extractKeyFromItem($subItem); + if ($subKey !== null) { + $validKeys[] = $subKey; } } - if (! \in_array($value, $validKeys, strict: true)) { - return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } - - return null; } } - if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { - $constExpr = $targetType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + if (! \in_array($value, $validKeys, strict: true)) { + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } - $constValue = $this->resolveConstantValue($fqcn, $constName); + return null; + } - if (\is_array($constValue)) { - if ((! \is_int($value) && ! \is_string($value)) || ! \array_key_exists($value, $constValue)) { - return ErrorFactory::createError($context . " must be a key of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + private function validateKeyOfEnum(mixed $value, string $enumClass, string $context, bool $isSensitive): ?ErrorMessage + { + if (! isset(self::$enumKeyCache[$enumClass])) { + self::$enumKeyCache[$enumClass] = array_map(fn($case) => $case->name, $enumClass::cases()); + } - return null; - } - } elseif ($targetType instanceof IdentifierTypeNode) { - $enumClass = $targetType->name; - if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass)) { - if (! isset(self::$enumKeyCache[$enumClass])) { - self::$enumKeyCache[$enumClass] = array_map(fn ($case) => $case->name, $enumClass::cases()); - } + if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { + return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } - if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { - return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + return null; + } - return null; - } - } elseif ($targetType instanceof ArrayShapeNode) { - $validKeys = []; - $nextAutoIndex = 0; - - foreach ($targetType->items as $item) { - if ($item->keyName instanceof ConstExprStringNode) { - $validKeys[] = $item->keyName->value; - } elseif ($item->keyName instanceof IdentifierTypeNode) { - $validKeys[] = $item->keyName->name; - } elseif ($item->keyName instanceof ConstExprIntegerNode) { - $key = (int) $item->keyName->value; - $validKeys[] = $key; - $nextAutoIndex = max($nextAutoIndex, $key + 1); - } elseif ($item->keyName !== null) { - $validKeys[] = (string) $item->keyName; - } else { - $validKeys[] = $nextAutoIndex; - $nextAutoIndex++; - } + private function validateKeyOfArrayShape(mixed $value, ArrayShapeNode $targetType, string $context, bool $isSensitive): ?ErrorMessage + { + $validKeys = []; + $nextAutoIndex = 0; + + foreach ($targetType->items as $item) { + $key = self::extractKeyFromItem($item); + if ($key === null) { + $key = $nextAutoIndex; + $nextAutoIndex++; + } elseif (\is_int($key)) { + $nextAutoIndex = max($nextAutoIndex, $key + 1); } - if (! \in_array($value, $validKeys, strict: true)) { - return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + $validKeys[] = $key; + } - return null; + if (! \in_array($value, $validKeys, strict: true)) { + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } - private static function extractKeyFromItem(ArrayShapeItemNode $item): string|int|null + private function validateKeyOfConstant(mixed $value, string $className, string $constName, string $context, bool $isSensitive): ?ErrorMessage { - $keyName = $item->keyName; + if ($constName === '') { + return null; + } - if ($keyName instanceof ConstExprStringNode) { - return $keyName->value; + $cacheKey = $className !== '' ? "$className::$constName" : $constName; + $constValue = $this->resolveConstantValue($className, $constName); + + if (\is_array($constValue)) { + if ((! \is_int($value) && ! \is_string($value)) || ! \array_key_exists($value, $constValue)) { + return ErrorFactory::createError($context . " must be a key of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } } - if ($keyName instanceof ConstExprIntegerNode) { - return (int) $keyName->value; + + return null; + } + + private function validateValueOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage + { + $targetType = $node->genericTypes[0] ?? null; + if ($targetType === null) { + return null; } - if ($keyName instanceof IdentifierTypeNode) { - return $keyName->name; + + if ($targetType instanceof IdentifierTypeNode && ClassNameValidator::isValid($targetType->name) && enum_exists($targetType->name)) { + return $this->validateValueOfEnum($value, $targetType->name, $context, $isSensitive); } - if ($keyName instanceof ConstFetchNode) { - return (string) $keyName; + + if ($targetType instanceof ArrayShapeNode) { + return $this->validateValueOfArrayShape($value, $targetType, $context, $registry, $isSensitive); + } + + $target = $this->extractConstantTarget($targetType); + if ($target !== null) { + return $this->validateValueOfConstant($value, $target[0], $target[1], $context, $isSensitive); } return null; } - private function validateValueOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage + private function validateValueOfEnum(mixed $value, string $enumClass, string $context, bool $isSensitive): ?ErrorMessage { - $targetType = $node->genericTypes[0] ?? null; + if (! is_subclass_of($enumClass, \BackedEnum::class)) { + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } - if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { - $constExpr = $targetType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + if (! isset(self::$enumValueCache[$enumClass])) { + self::$enumValueCache[$enumClass] = array_map(fn($case) => $case->value, $enumClass::cases()); + } - $constValue = $this->resolveConstantValue($fqcn, $constName); + if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } - if (\is_array($constValue)) { - if (! \in_array($value, $constValue, strict: true)) { - return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + return null; + } + private function validateValueOfArrayShape(mixed $value, ArrayShapeNode $targetType, string $context, TypeValidatorRegistry $registry, bool $isSensitive): ?ErrorMessage + { + foreach ($targetType->items as $item) { + if ($registry->validate($value, $item->valueType, '', $isSensitive) === null) { return null; } - } elseif ($targetType instanceof IdentifierTypeNode) { - $enumClass = $targetType->name; - if (ClassNameValidator::isValid($enumClass) && enum_exists($enumClass)) { - if (is_subclass_of($enumClass, \BackedEnum::class)) { - if (! isset(self::$enumValueCache[$enumClass])) { - self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases()); - } + } - if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { - return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + return ErrorFactory::createError($context . ' must be a value of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } - return null; - } + private function validateValueOfConstant(mixed $value, string $className, string $constName, string $context, bool $isSensitive): ?ErrorMessage + { + if ($constName === '') { + return null; + } - return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } - } elseif ($targetType instanceof ArrayShapeNode) { - foreach ($targetType->items as $item) { - if ($registry->validate($value, $item->valueType, '', $isSensitive) === null) { - return null; - } - } + $cacheKey = $className !== '' ? "$className::$constName" : $constName; + $constValue = $this->resolveConstantValue($className, $constName); - return ErrorFactory::createError($context . ' must be a value of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + if (\is_array($constValue) && ! \in_array($value, $constValue, strict: true)) { + return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -274,9 +270,23 @@ private function validateIntMask(mixed $value, GenericTypeNode $node, string $co return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } + $allowedMask = $this->computeAllowedMask($node->genericTypes); + + if (($value & ~$allowedMask) !== 0) { + return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + + return null; + } + + /** + * @param array $genericTypes + */ + private function computeAllowedMask(array $genericTypes): int + { $allowedMask = 0; - foreach ($node->genericTypes as $typeNode) { + foreach ($genericTypes as $typeNode) { if ($typeNode instanceof ConstTypeNode) { $expr = $typeNode->constExpr; if ($expr instanceof ConstExprIntegerNode) { @@ -288,25 +298,17 @@ private function validateIntMask(mixed $value, GenericTypeNode $node, string $co } } } elseif ($typeNode instanceof IdentifierTypeNode) { - $constName = $typeNode->name; - if (str_contains($constName, '::')) { - [$className, $name] = explode('::', $constName, 2); - $constVal = $this->resolveConstantValue($className, $name); - } else { - $constVal = $this->resolveConstantValue('', $constName); - } - - if (\is_int($constVal)) { - $allowedMask |= $constVal; + $target = $this->extractConstantTarget($typeNode); + if ($target !== null) { + $constVal = $this->resolveConstantValue($target[0], $target[1]); + if (\is_int($constVal)) { + $allowedMask |= $constVal; + } } } } - if (($value & ~$allowedMask) !== 0) { - return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } - - return null; + return $allowedMask; } private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage @@ -316,107 +318,19 @@ private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $ } $targetType = $node->genericTypes[0] ?? null; - $allowedMask = 0; - $foundFlags = false; - - $className = ''; - $pattern = ''; - - if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { - $className = $targetType->constExpr->className; - $pattern = $targetType->constExpr->name; - } elseif ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstExprStringNode) { - $className = ''; - $pattern = $targetType->constExpr->value; - } elseif ($targetType instanceof IdentifierTypeNode) { - if (str_contains($targetType->name, '::')) { - [$className, $pattern] = explode('::', $targetType->name, 2); - } else { - $className = ''; - $pattern = $targetType->name; - } - } elseif ($targetType instanceof UnionTypeNode) { - foreach ($targetType->types as $unionMember) { - if ($unionMember instanceof ConstTypeNode) { - $expr = $unionMember->constExpr; - if ($expr instanceof ConstExprIntegerNode) { - $allowedMask |= (int) $expr->value; - $foundFlags = true; - } elseif ($expr instanceof ConstFetchNode) { - $val = $this->resolveConstantValue($expr->className, $expr->name); - if (\is_int($val)) { - $allowedMask |= $val; - $foundFlags = true; - } - } - } elseif ($unionMember instanceof IdentifierTypeNode) { - $name = $unionMember->name; - if (str_contains($name, '::')) { - [$cls, $cName] = explode('::', $name, 2); - $val = $this->resolveConstantValue($cls, $cName); - } else { - $val = $this->resolveConstantValue('', $name); - } - if (\is_int($val)) { - $allowedMask |= $val; - $foundFlags = true; - } - } - } + if ($targetType === null) { + return null; } - if ($pattern !== '') { - $cacheKey = $className !== '' ? "$className::$pattern" : $pattern; - - if (isset(self::$maskOfCache[$cacheKey])) { - [$allowedMask, $foundFlags] = self::$maskOfCache[$cacheKey]; - } else { - if ($className !== '') { - if (class_exists($className) || interface_exists($className)) { - $refClass = new \ReflectionClass($className); - - if (str_contains($pattern, '*')) { - $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; - foreach ($refClass->getConstants() as $cName => $cValue) { - if (\is_int($cValue) && preg_match($regex, $cName) === 1) { - $allowedMask |= $cValue; - $foundFlags = true; - } - } - } else { - $cValue = $this->resolveConstantValue($className, $pattern); - if (\is_int($cValue)) { - $allowedMask |= $cValue; - $foundFlags = true; - } elseif (\is_array($cValue)) { - foreach ($cValue as $item) { - if (\is_int($item)) { - $allowedMask |= $item; - $foundFlags = true; - } - } - } - } - } - } else { - if (str_contains($pattern, '*')) { - $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; - foreach (get_defined_constants() as $cName => $cValue) { - if (\is_int($cValue) && preg_match($regex, $cName) === 1) { - $allowedMask |= $cValue; - $foundFlags = true; - } - } - } else { - $cValue = $this->resolveConstantValue('', $pattern); - if (\is_int($cValue)) { - $allowedMask |= $cValue; - $foundFlags = true; - } - } - } + $allowedMask = 0; + $foundFlags = false; - self::$maskOfCache[$cacheKey] = [$allowedMask, $foundFlags]; + if ($targetType instanceof UnionTypeNode) { + [$allowedMask, $foundFlags] = $this->computeMaskOfUnion($targetType); + } else { + $target = $this->extractConstantTarget($targetType); + if ($target !== null && $target[1] !== '') { + [$allowedMask, $foundFlags] = $this->resolveMaskOfPattern($target[0], $target[1]); } } @@ -427,100 +341,102 @@ private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $ return null; } - private function validateIntRange(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage + /** + * @return array{0: int, 1: bool} + */ + private function computeMaskOfUnion(UnionTypeNode $targetType): array { - if (! \is_int($value)) { - return ErrorFactory::createError($context . ' must be of type int, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } - - $minNode = $node->genericTypes[0] ?? null; - $maxNode = $node->genericTypes[1] ?? null; - - if ($minNode !== null) { - $minStr = strtolower(trim((string) $minNode)); - if ($minStr !== 'min' && $minStr !== '*') { - $minVal = (int) $minStr; - if ($value < $minVal) { - $valDisplay = $isSensitive ? 'int given' : "$value given"; + $allowedMask = 0; + $foundFlags = false; - return ErrorFactory::createError($context . " must be >= $minVal, $valDisplay"); + foreach ($targetType->types as $unionMember) { + if ($unionMember instanceof ConstTypeNode) { + $expr = $unionMember->constExpr; + if ($expr instanceof ConstExprIntegerNode) { + $allowedMask |= (int) $expr->value; + $foundFlags = true; + } elseif ($expr instanceof ConstFetchNode) { + $val = $this->resolveConstantValue($expr->className, $expr->name); + if (\is_int($val)) { + $allowedMask |= $val; + $foundFlags = true; + } } - } - } - - if ($maxNode !== null) { - $maxStr = strtolower(trim((string) $maxNode)); - if ($maxStr !== 'max' && $maxStr !== '*') { - $maxVal = (int) $maxStr; - if ($value > $maxVal) { - $valDisplay = $isSensitive ? 'int given' : "$value given"; - - return ErrorFactory::createError($context . " must be <= $maxVal, $valDisplay"); + } elseif ($unionMember instanceof IdentifierTypeNode) { + $target = $this->extractConstantTarget($unionMember); + if ($target !== null) { + $val = $this->resolveConstantValue($target[0], $target[1]); + if (\is_int($val)) { + $allowedMask |= $val; + $foundFlags = true; + } } } } - return null; + return [$allowedMask, $foundFlags]; } - private function validateClassString(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage + /** + * @return array{0: int, 1: bool} + */ + private function resolveMaskOfPattern(string $className, string $pattern): array { - if (! \is_string($value) || ! ClassNameValidator::isValidClassString($value)) { - return ErrorFactory::createError($context . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); - } + $cacheKey = $className !== '' ? "$className::$pattern" : $pattern; - $targetClassNode = $node->genericTypes[0] ?? null; - if ($targetClassNode === null) { - return null; + if (isset(self::$maskOfCache[$cacheKey])) { + return self::$maskOfCache[$cacheKey]; } - return $this->validateClassStringBound($value, $targetClassNode, $context, $isSensitive); - } - - private function validateClassStringBound(string $value, TypeNode $targetNode, string $context, bool $isSensitive = false): ?ErrorMessage - { - if ($targetNode instanceof IdentifierTypeNode) { - $targetName = $targetNode->name; - $lower = strtolower($targetName); - if ($lower === 'object' || $lower === 'mixed') { - return null; - } + $allowedMask = 0; + $foundFlags = false; - if (class_exists($targetName) || interface_exists($targetName) || trait_exists($targetName) || enum_exists($targetName)) { - if (! is_a($value, $targetName, allow_string: true)) { - $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + if ($className !== '') { + if (class_exists($className) || interface_exists($className)) { + $refClass = new \ReflectionClass($className); - return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", $valDisplay"); + if (str_contains($pattern, '*')) { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + foreach ($refClass->getConstants() as $cName => $cValue) { + if (\is_int($cValue) && preg_match($regex, $cName) === 1) { + $allowedMask |= $cValue; + $foundFlags = true; + } + } + } else { + $cValue = $this->resolveConstantValue($className, $pattern); + if (\is_int($cValue)) { + $allowedMask |= $cValue; + $foundFlags = true; + } elseif (\is_array($cValue)) { + foreach ($cValue as $item) { + if (\is_int($item)) { + $allowedMask |= $item; + $foundFlags = true; + } + } + } } } - - return null; - } - - if ($targetNode instanceof UnionTypeNode) { - foreach ($targetNode->types as $unionType) { - if ($this->validateClassStringBound($value, $unionType, $context, $isSensitive) === null) { - return null; + } else { + if (str_contains($pattern, '*')) { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + foreach (get_defined_constants() as $cName => $cValue) { + if (\is_int($cValue) && preg_match($regex, $cName) === 1) { + $allowedMask |= $cValue; + $foundFlags = true; + } } - } - - $valDisplay = $isSensitive ? 'string given' : "'$value' given"; - - return ErrorFactory::createError($context . ' must be a class-string of ' . (string) $targetNode . ", $valDisplay"); - } - - if ($targetNode instanceof IntersectionTypeNode) { - foreach ($targetNode->types as $intersectionType) { - $err = $this->validateClassStringBound($value, $intersectionType, $context, $isSensitive); - if ($err !== null) { - return $err; + } else { + $cValue = $this->resolveConstantValue('', $pattern); + if (\is_int($cValue)) { + $allowedMask |= $cValue; + $foundFlags = true; } } - - return null; } - return null; + return self::$maskOfCache[$cacheKey] = [$allowedMask, $foundFlags]; } private function validateList(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage @@ -605,107 +521,281 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont } $typesCount = \count($node->genericTypes); + if ($typesCount === 1) { - $valTypeNode = $node->genericTypes[0]; + return $this->validateSingleTypeArray($value, $node->genericTypes[0], $context, $registry, $isSensitive, $count); + } - if ($valTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { - return null; + if ($typesCount >= 2) { + return $this->validateKeyValueTypeArray($value, $node->genericTypes[0], $node->genericTypes[1], $context, $registry, $isSensitive, $count); + } + + return null; + } + + /** + * @param array $value + */ + private function validateSingleTypeArray( + array $value, + TypeNode $valTypeNode, + string $context, + TypeValidatorRegistry $registry, + bool $isSensitive, + int $count + ): ?ErrorMessage { + if ($valTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { + return null; + } + + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); + + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + $keys = array_keys($value); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + } + + foreach ($sampleKeys as $k) { + $v = $value[$k]; + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); + + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + } + } + + return null; + } + + foreach ($value as $k => $v) { + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); + + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + } + } + + return null; + } + + /** + * @param array $value + */ + private function validateKeyValueTypeArray( + array $value, + TypeNode $keyTypeNode, + TypeNode $valTypeNode, + string $context, + TypeValidatorRegistry $registry, + bool $isSensitive, + int $count + ): ?ErrorMessage { + $keyIsArrayKey = ($keyTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($keyTypeNode->name), ['array-key', 'mixed', 'tkey', 'key', 'k'], true); + $valIsMixed = ($valTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($valTypeNode->name), ['mixed', 'tvalue', 'v', 'value', 't'], true); + + if ($keyIsArrayKey && $valIsMixed) { + return null; + } + + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); + + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + $keys = array_keys($value); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; } - $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - $keys = array_keys($value); - $sampleKeys = [$keys[0], $keys[$count - 1]]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + foreach ($sampleKeys as $k) { + if (! $keyIsArrayKey) { + $err = $registry->validate($k, $keyTypeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($context . ' key' . $err->getMessage()); + } } - foreach ($sampleKeys as $k) { + if (! $valIsMixed) { $v = $value[$k]; $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); } } + } - return null; + return null; + } + + foreach ($value as $k => $v) { + if (! $keyIsArrayKey) { + $err = $registry->validate($k, $keyTypeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($context . ' key' . $err->getMessage()); + } } - foreach ($value as $k => $v) { + if (! $valIsMixed) { + $v = $value[$k]; $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + } + } + } + + return null; + } + + private function resolveIntBound(PhpDocNode $node): ?int + { + if ($node instanceof ConstExprIntegerNode) { + return (int) $node->value; + } + + if ($node instanceof ConstTypeNode) { + $expr = $node->constExpr; + if ($expr instanceof ConstExprIntegerNode) { + return (int) $expr->value; + } + if ($expr instanceof ConstFetchNode) { + $val = $this->resolveConstantValue($expr->className, $expr->name); + if (\is_int($val)) { + return $val; } } - } elseif ($typesCount >= 2) { - $keyTypeNode = $node->genericTypes[0]; - $valTypeNode = $node->genericTypes[1]; + } - $keyIsArrayKey = ($keyTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($keyTypeNode->name), ['array-key', 'mixed', 'tkey', 'key', 'k'], true); - $valIsMixed = ($valTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($valTypeNode->name), ['mixed', 'tvalue', 'v', 'value', 't'], true); + if ($node instanceof ConstFetchNode) { + $val = $this->resolveConstantValue($node->className, $node->name); + if (\is_int($val)) { + return $val; + } + } - if ($keyIsArrayKey && $valIsMixed) { + if ($node instanceof IdentifierTypeNode) { + $name = $node->name; + $lower = strtolower($name); + if ($lower === 'min' || $lower === 'max' || $lower === '*') { return null; } - $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); + if (is_numeric($name)) { + return (int) $name; + } - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - $keys = array_keys($value); - $sampleKeys = [$keys[0], $keys[$count - 1]]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + $target = $this->extractConstantTarget($node); + if ($target !== null) { + $val = $this->resolveConstantValue($target[0], $target[1]); + if (\is_int($val)) { + return $val; } + } + } - foreach ($sampleKeys as $k) { - if (! $keyIsArrayKey) { - $err = $registry->validate($k, $keyTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . ' key' . $err->getMessage()); - } - } + $str = (string) $node; + if (is_numeric($str)) { + return (int) $str; + } - if (! $valIsMixed) { - $v = $value[$k]; - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) - : $registry->validate($v, $valTypeNode, '', $isSensitive); + return null; + } - if ($err !== null) { - return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); - } - } - } + private function validateIntRange(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage + { + if (! \is_int($value)) { + return ErrorFactory::createError($context . ' must be of type int, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + + $minNode = $node->genericTypes[0] ?? null; + $maxNode = $node->genericTypes[1] ?? null; + + $minVal = $minNode !== null ? $this->resolveIntBound($minNode) : null; + $maxVal = $maxNode !== null ? $this->resolveIntBound($maxNode) : null; + + if ($minVal !== null && $value < $minVal) { + $valDisplay = $isSensitive ? 'int given' : "$value given"; + + return ErrorFactory::createError($context . " must be >= $minVal, $valDisplay"); + } + + if ($maxVal !== null && $value > $maxVal) { + $valDisplay = $isSensitive ? 'int given' : "$value given"; + + return ErrorFactory::createError($context . " must be <= $maxVal, $valDisplay"); + } + + return null; + } + + private function validateClassString(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage + { + if (! \is_string($value) || ! ClassNameValidator::isValidClassString($value)) { + return ErrorFactory::createError($context . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + + $targetClassNode = $node->genericTypes[0] ?? null; + if ($targetClassNode === null) { + return null; + } + + return $this->validateClassStringBound($value, $targetClassNode, $context, $isSensitive); + } + private function validateClassStringBound(string $value, TypeNode $targetNode, string $context, bool $isSensitive = false): ?ErrorMessage + { + if ($targetNode instanceof IdentifierTypeNode) { + $targetName = $targetNode->name; + $lower = strtolower($targetName); + if ($lower === 'object' || $lower === 'mixed') { return null; } - foreach ($value as $k => $v) { - if (! $keyIsArrayKey) { - $err = $registry->validate($k, $keyTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . ' key' . $err->getMessage()); - } + if (class_exists($targetName) || interface_exists($targetName) || trait_exists($targetName) || enum_exists($targetName)) { + if (! is_a($value, $targetName, allow_string: true)) { + $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + + return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", $valDisplay"); } + } - if (! $valIsMixed) { - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) - : $registry->validate($v, $valTypeNode, '', $isSensitive); + return null; + } - if ($err !== null) { - return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); - } + if ($targetNode instanceof UnionTypeNode) { + foreach ($targetNode->types as $unionType) { + if ($this->validateClassStringBound($value, $unionType, $context, $isSensitive) === null) { + return null; } } + + $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + + return ErrorFactory::createError($context . ' must be a class-string of ' . (string) $targetNode . ", $valDisplay"); + } + + if ($targetNode instanceof IntersectionTypeNode) { + foreach ($targetNode->types as $intersectionType) { + $err = $this->validateClassStringBound($value, $intersectionType, $context, $isSensitive); + if ($err !== null) { + return $err; + } + } + + return null; } return null; @@ -727,4 +817,80 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } + + // ========================================================================= + // SHARED UTILITIES + // ========================================================================= + + /** + * Extracts constant class name and constant name from ConstTypeNode or IdentifierTypeNode. + * + * @return array{0: string, 1: string}|null + */ + private function extractConstantTarget(?TypeNode $targetType): ?array + { + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { + return [$targetType->constExpr->className, $targetType->constExpr->name]; + } + + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstExprStringNode) { + return ['', $targetType->constExpr->value]; + } + + if ($targetType instanceof IdentifierTypeNode) { + if (str_contains($targetType->name, '::')) { + [$className, $constName] = explode('::', $targetType->name, 2); + + return [$className, $constName]; + } + + return ['', $targetType->name]; + } + + return null; + } + + private static function extractKeyFromItem(ArrayShapeItemNode $item): string|int|null + { + $keyName = $item->keyName; + + if ($keyName instanceof ConstExprStringNode) { + return $keyName->value; + } + if ($keyName instanceof ConstExprIntegerNode) { + return (int) $keyName->value; + } + if ($keyName instanceof IdentifierTypeNode) { + return $keyName->name; + } + if ($keyName instanceof ConstFetchNode) { + return (string) $keyName; + } + + return null; + } + + private function resolveConstantValue(string $fqcn, string $constName): mixed + { + $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + + if (! \array_key_exists($cacheKey, self::$constantCache)) { + $constValue = false; + if ($fqcn !== '') { + if (class_exists($fqcn) || interface_exists($fqcn)) { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + } + } + } else { + if (\defined($constName)) { + $constValue = \constant($constName); + } + } + self::$constantCache[$cacheKey] = $constValue; + } + + return self::$constantCache[$cacheKey]; + } } diff --git a/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php new file mode 100644 index 0000000..e7fdcd7 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php @@ -0,0 +1,211 @@ + 30, + 'retries' => 3, + 'verbose' => 0, +]; + +const TEST_GLOBAL_ROLES = [ + 'admin' => 1, + 'user' => 2, + 'guest' => 3, +]; + +const TEST_GLOBAL_MAX_RETRIES = 5; + +if (! \defined('TEST_GLOBAL_HTTP_CODES')) { + \define('TEST_GLOBAL_HTTP_CODES', [ + 'ok' => 200, + 'not_found' => 404, + 'server_error' => 500, + ]); +} + +/** + * @param key-of $key + */ +function testGlobalConstKeyOf(string $key): int +{ + return TEST_GLOBAL_DEFAULTS[$key] ?? 0; +} + +/** + * @param key-of $key + */ +function testGlobalDefineKeyOf(string $key): int +{ + return TEST_GLOBAL_HTTP_CODES[$key] ?? 0; +} + +/** + * @param value-of $value + */ +function testGlobalConstValueOf(int $value): string +{ + return "Value: {$value}"; +} + +/** + * @param value-of $code + */ +function testGlobalDefineValueOf(int $code): string +{ + return "HTTP: {$code}"; +} + +/** + * @param array{key: key-of, override: int} $config + */ +function testGlobalKeyOfInShape(array $config): int +{ + return $config['override'] !== 0 ? $config['override'] : (TEST_GLOBAL_DEFAULTS[$config['key']] ?? 0); +} + +/** + * @param array{role: value-of, user: string} $data + */ +function testGlobalValueOfInShape(array $data): string +{ + return "{$data['user']}:{$data['role']}"; +} + +/** + * @param list> $keys + */ +function testGlobalKeyOfInList(array $keys): int +{ + return \count($keys); +} + +/** + * @return key-of + */ +function testGlobalKeyOfReturn(string $key): string +{ + return $key; +} + +/** + * @param int<1, TEST_GLOBAL_MAX_RETRIES> $n + */ +function testGlobalConstantIntRange(int $n): int +{ + return $n; +} + +describe('Global Constant key-of, value-of, and int Bounds', function () { + describe('key-of with Global Constants', function () { + test('accepts valid keys from global array constant declared via const', function () { + expect(testGlobalConstKeyOf('timeout'))->toBe(30) + ->and(testGlobalConstKeyOf('retries'))->toBe(3) + ->and(testGlobalConstKeyOf('verbose'))->toBe(0) + ; + }); + + test('rejects undeclared keys for global array constant declared via const', function () { + expect(fn() => testGlobalConstKeyOf('nonexistent')) + ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_DEFAULTS, string \'nonexistent\' given'); + }); + + test('accepts valid keys from global array constant defined via define()', function () { + expect(testGlobalDefineKeyOf('ok'))->toBe(200) + ->and(testGlobalDefineKeyOf('not_found'))->toBe(404) + ->and(testGlobalDefineKeyOf('server_error'))->toBe(500) + ; + }); + + test('rejects undeclared keys for global array constant defined via define()', function () { + expect(fn() => testGlobalDefineKeyOf('invalid')) + ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_HTTP_CODES, string \'invalid\' given'); + }); + }); + + describe('value-of with Global Constants', function () { + test('accepts valid values from global array constant declared via const', function () { + expect(testGlobalConstValueOf(30))->toBe('Value: 30') + ->and(testGlobalConstValueOf(3))->toBe('Value: 3') + ->and(testGlobalConstValueOf(0))->toBe('Value: 0') + ; + }); + + test('rejects undeclared values for global array constant declared via const', function () { + expect(fn() => testGlobalConstValueOf(999)) + ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_DEFAULTS, int (999) given'); + }); + + test('accepts valid values from global array constant defined via define()', function () { + expect(testGlobalDefineValueOf(200))->toBe('HTTP: 200') + ->and(testGlobalDefineValueOf(404))->toBe('HTTP: 404') + ->and(testGlobalDefineValueOf(500))->toBe('HTTP: 500') + ; + }); + + test('rejects undeclared values for global array constant defined via define()', function () { + expect(fn() => testGlobalDefineValueOf(418)) + ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_HTTP_CODES, int (418) given'); + }); + }); + + describe('Nested Positions (Shapes, Lists, @var, @return)', function () { + test('validates key-of inside array shape', function () { + expect(testGlobalKeyOfInShape(['key' => 'timeout', 'override' => 0]))->toBe(30); + + expect(fn() => testGlobalKeyOfInShape(['key' => 'invalid', 'override' => 0])) + ->toThrow(TypeError::class, "Argument \$config['key'] must be a key of TEST_GLOBAL_DEFAULTS, string 'invalid' given"); + }); + + test('validates value-of inside array shape', function () { + expect(testGlobalValueOfInShape(['role' => 1, 'user' => 'Alice']))->toBe('Alice:1'); + + expect(fn() => testGlobalValueOfInShape(['role' => 99, 'user' => 'Alice'])) + ->toThrow(TypeError::class, "Argument \$data['role'] must be a value of TEST_GLOBAL_ROLES, int (99) given"); + }); + + test('validates key-of inside generic list', function () { + expect(testGlobalKeyOfInList(['admin', 'user']))->toBe(2); + + expect(fn() => testGlobalKeyOfInList(['admin', 'superuser'])) + ->toThrow(TypeError::class, "Argument \$keys[1] must be a key of TEST_GLOBAL_ROLES, string 'superuser' given"); + }); + + test('validates key-of on return value', function () { + expect(testGlobalKeyOfReturn('timeout'))->toBe('timeout'); + + expect(fn() => testGlobalKeyOfReturn('invalid_key')) + ->toThrow(TypeError::class, 'Return value must be a key of TEST_GLOBAL_DEFAULTS'); + }); + + test('validates key-of on inline @var variable assignment', function () { + /** @var key-of $valid */ + $valid = 'timeout'; + expect($valid)->toBe('timeout'); + + expect(function () { + /** @var key-of $bad */ + $bad = 'unknown_field'; + })->toThrow(TypeError::class, 'Variable $bad must be a key of TEST_GLOBAL_DEFAULTS, string \'unknown_field\' given'); + }); + }); + + describe('Global Scalar Constants in int Ranges', function () { + test('resolves global constant as range bound (not evaluating to 0)', function () { + expect(testGlobalConstantIntRange(1))->toBe(1) + ->and(testGlobalConstantIntRange(3))->toBe(3) + ->and(testGlobalConstantIntRange(5))->toBe(5) + ; + }); + + test('rejects values exceeding global constant max bound', function () { + expect(fn() => testGlobalConstantIntRange(10)) + ->toThrow(TypeError::class, 'Argument $n must be <= 5, 10 given'); + + expect(fn() => testGlobalConstantIntRange(0)) + ->toThrow(TypeError::class, 'Argument $n must be >= 1, 0 given'); + }); + }); +}); From d83c5bf2cb334a8410c7bf0adaf3771ed813eab9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 3 Oct 2026 18:36:42 +0800 Subject: [PATCH 2/3] Enhance DocblockParser to support promoted properties in constructor validation and add tests for property mutation with typed parameters --- src/Internal/Docblock/DocblockParser.php | 19 ++- .../PromotedPropertyParamMutationTest.php | 135 ++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 4e22546..81012ea 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -476,6 +476,17 @@ public static function parseProperty(string $className, string $propertyName): ? } } + if ($doc === false && $typeNode === null && $refClass->hasProperty($propertyName)) { + $refProp = $refClass->getProperty($propertyName); + if ($refProp->isPromoted() && $refClass->hasMethod('__construct')) { + $ctorTarget = $refClass->getName() . '::__construct'; + $ctorContract = self::parse($ctorTarget); + if (isset($ctorContract['types'][$propertyName])) { + return self::$propertyCache[$className][$propertyName] = $ctorContract['types'][$propertyName]; + } + } + } + if ($doc === false || $declaringClass === null || self::shouldIgnoreDoc($doc)) { return self::$propertyCache[$className][$propertyName] = null; } @@ -1673,7 +1684,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -1707,7 +1718,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo 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 ); @@ -1724,7 +1735,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1739,7 +1750,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php b/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php new file mode 100644 index 0000000..535f502 --- /dev/null +++ b/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php @@ -0,0 +1,135 @@ +name = 'Rex'; + expect($animal->name)->toBe('Rex'); + + expect(fn () => $animal->name = 123) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedParamAnimal::$name must be of type string, int (123) given') + ; + }); + + test('validates scalar refinement on promoted property mutation', function () { + $animal = new PromotedParamAnimal(); + + $animal->age = 5; + expect($animal->age)->toBe(5); + + expect(fn () => $animal->age = -1) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedParamAnimal::$age must be of type positive-int, negative int (-1) given') + ; + }); + + test('validates class object on promoted property mutation', function () { + $dog = new Dog(); + $owner = new PromotedParamWithClassType($dog); + + $newDog = new Dog(); + $owner->pet = $newDog; + expect($owner->pet)->toBe($newDog); + + expect(fn () => $owner->pet = new Car()) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedParamWithClassType::$pet must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + }); + + describe('Generic Promoted Properties (@template T)', function () { + test('validates mutation on promoted property bound to generic template T', function () { + /** @var PromotedGenericBox $box */ + $box = new PromotedGenericBox(new Dog()); + + $newDog = new Dog(); + $box->item = $newDog; + expect($box->item)->toBe($newDog); + + expect(fn () => $box->item = new Car()) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedGenericBox::$item must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + + test('validates multiple promoted generic properties on mutation (Map)', function () { + /** @var PromotedGenericMap $map */ + $map = new PromotedGenericMap('alpha', 10); + + $map->key = 'beta'; + $map->val = 20; + expect($map->key)->toBe('beta')->and($map->val)->toBe(20); + + expect(fn () => $map->key = 123) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedGenericMap::$key must be of type string') + ; + + expect(fn () => $map->val = -5) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\TypeChecking\Boundaries\PromotedGenericMap::$val must be of type positive-int') + ; + }); + }); +}); \ No newline at end of file From 75493e32dab9d4771a7620813e23433a9a56c408 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 3 Oct 2026 18:57:43 +0800 Subject: [PATCH 3/3] Refactor code style for consistency and readability in DocblockParser, GenericValidator, and GlobalConstantKeyOfValueOfTest --- src/Internal/Docblock/DocblockParser.php | 8 ++-- src/Internal/Validator/GenericValidator.php | 22 +++++++-- .../GlobalConstantKeyOfValueOfTest.php | 47 +++++++++++-------- .../PromotedPropertyParamMutationTest.php | 2 +- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 81012ea..83f5014 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -1684,7 +1684,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -1718,7 +1718,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo 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 ); @@ -1735,7 +1735,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1750,7 +1750,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 3b26cbc..8188b54 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -7,6 +7,7 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; +use PHPStan\PhpDocParser\Ast\Node as PhpDocNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; @@ -15,7 +16,6 @@ use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; -use PHPStan\PhpDocParser\Ast\Node as PhpDocNode; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; @@ -142,10 +142,20 @@ private function validateKeyOfNestedValueOf(mixed $value, GenericTypeNode $targe return null; } + /** + * @param class-string<\UnitEnum>|string $enumClass + */ private function validateKeyOfEnum(mixed $value, string $enumClass, string $context, bool $isSensitive): ?ErrorMessage { + if (! is_subclass_of($enumClass, \UnitEnum::class)) { + return null; + } + if (! isset(self::$enumKeyCache[$enumClass])) { - self::$enumKeyCache[$enumClass] = array_map(fn($case) => $case->name, $enumClass::cases()); + self::$enumKeyCache[$enumClass] = array_map( + static fn (\UnitEnum $case): string => $case->name, + $enumClass::cases() + ); } if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { @@ -220,6 +230,9 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co return null; } + /** + * @param class-string<\BackedEnum>|string $enumClass + */ private function validateValueOfEnum(mixed $value, string $enumClass, string $context, bool $isSensitive): ?ErrorMessage { if (! is_subclass_of($enumClass, \BackedEnum::class)) { @@ -227,7 +240,10 @@ private function validateValueOfEnum(mixed $value, string $enumClass, string $co } if (! isset(self::$enumValueCache[$enumClass])) { - self::$enumValueCache[$enumClass] = array_map(fn($case) => $case->value, $enumClass::cases()); + self::$enumValueCache[$enumClass] = array_map( + static fn (\BackedEnum $case): string|int => $case->value, + $enumClass::cases() + ); } if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { diff --git a/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php index e7fdcd7..2e83a88 100644 --- a/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php +++ b/tests/TypeChecking/ArraysAndShapes/GlobalConstantKeyOfValueOfTest.php @@ -108,8 +108,9 @@ function testGlobalConstantIntRange(int $n): int }); test('rejects undeclared keys for global array constant declared via const', function () { - expect(fn() => testGlobalConstKeyOf('nonexistent')) - ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_DEFAULTS, string \'nonexistent\' given'); + expect(fn () => testGlobalConstKeyOf('nonexistent')) + ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_DEFAULTS, string \'nonexistent\' given') + ; }); test('accepts valid keys from global array constant defined via define()', function () { @@ -120,8 +121,9 @@ function testGlobalConstantIntRange(int $n): int }); test('rejects undeclared keys for global array constant defined via define()', function () { - expect(fn() => testGlobalDefineKeyOf('invalid')) - ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_HTTP_CODES, string \'invalid\' given'); + expect(fn () => testGlobalDefineKeyOf('invalid')) + ->toThrow(TypeError::class, 'must be a key of TEST_GLOBAL_HTTP_CODES, string \'invalid\' given') + ; }); }); @@ -134,8 +136,9 @@ function testGlobalConstantIntRange(int $n): int }); test('rejects undeclared values for global array constant declared via const', function () { - expect(fn() => testGlobalConstValueOf(999)) - ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_DEFAULTS, int (999) given'); + expect(fn () => testGlobalConstValueOf(999)) + ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_DEFAULTS, int (999) given') + ; }); test('accepts valid values from global array constant defined via define()', function () { @@ -146,8 +149,9 @@ function testGlobalConstantIntRange(int $n): int }); test('rejects undeclared values for global array constant defined via define()', function () { - expect(fn() => testGlobalDefineValueOf(418)) - ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_HTTP_CODES, int (418) given'); + expect(fn () => testGlobalDefineValueOf(418)) + ->toThrow(TypeError::class, 'must be a value of TEST_GLOBAL_HTTP_CODES, int (418) given') + ; }); }); @@ -155,29 +159,33 @@ function testGlobalConstantIntRange(int $n): int test('validates key-of inside array shape', function () { expect(testGlobalKeyOfInShape(['key' => 'timeout', 'override' => 0]))->toBe(30); - expect(fn() => testGlobalKeyOfInShape(['key' => 'invalid', 'override' => 0])) - ->toThrow(TypeError::class, "Argument \$config['key'] must be a key of TEST_GLOBAL_DEFAULTS, string 'invalid' given"); + expect(fn () => testGlobalKeyOfInShape(['key' => 'invalid', 'override' => 0])) + ->toThrow(TypeError::class, "Argument \$config['key'] must be a key of TEST_GLOBAL_DEFAULTS, string 'invalid' given") + ; }); test('validates value-of inside array shape', function () { expect(testGlobalValueOfInShape(['role' => 1, 'user' => 'Alice']))->toBe('Alice:1'); - expect(fn() => testGlobalValueOfInShape(['role' => 99, 'user' => 'Alice'])) - ->toThrow(TypeError::class, "Argument \$data['role'] must be a value of TEST_GLOBAL_ROLES, int (99) given"); + expect(fn () => testGlobalValueOfInShape(['role' => 99, 'user' => 'Alice'])) + ->toThrow(TypeError::class, "Argument \$data['role'] must be a value of TEST_GLOBAL_ROLES, int (99) given") + ; }); test('validates key-of inside generic list', function () { expect(testGlobalKeyOfInList(['admin', 'user']))->toBe(2); - expect(fn() => testGlobalKeyOfInList(['admin', 'superuser'])) - ->toThrow(TypeError::class, "Argument \$keys[1] must be a key of TEST_GLOBAL_ROLES, string 'superuser' given"); + expect(fn () => testGlobalKeyOfInList(['admin', 'superuser'])) + ->toThrow(TypeError::class, "Argument \$keys[1] must be a key of TEST_GLOBAL_ROLES, string 'superuser' given") + ; }); test('validates key-of on return value', function () { expect(testGlobalKeyOfReturn('timeout'))->toBe('timeout'); - expect(fn() => testGlobalKeyOfReturn('invalid_key')) - ->toThrow(TypeError::class, 'Return value must be a key of TEST_GLOBAL_DEFAULTS'); + expect(fn () => testGlobalKeyOfReturn('invalid_key')) + ->toThrow(TypeError::class, 'Return value must be a key of TEST_GLOBAL_DEFAULTS') + ; }); test('validates key-of on inline @var variable assignment', function () { @@ -201,10 +209,11 @@ function testGlobalConstantIntRange(int $n): int }); test('rejects values exceeding global constant max bound', function () { - expect(fn() => testGlobalConstantIntRange(10)) - ->toThrow(TypeError::class, 'Argument $n must be <= 5, 10 given'); + expect(fn () => testGlobalConstantIntRange(10)) + ->toThrow(TypeError::class, 'Argument $n must be <= 5, 10 given') + ; - expect(fn() => testGlobalConstantIntRange(0)) + expect(fn () => testGlobalConstantIntRange(0)) ->toThrow(TypeError::class, 'Argument $n must be >= 1, 0 given'); }); }); diff --git a/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php b/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php index 535f502..9553e00 100644 --- a/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php +++ b/tests/TypeChecking/Boundaries/PromotedPropertyParamMutationTest.php @@ -132,4 +132,4 @@ public function __construct( ; }); }); -}); \ No newline at end of file +});