From 4ee6c091a7e4fb490ec6fb101cab9cd732519080 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 15 Sep 2026 13:25:48 +0800 Subject: [PATCH 1/4] Add ConditionalChecker and integrate conditional type resolution in ParamChecker, ReturnChecker, and DocblockParser; introduce tests for conditional parameters --- src/Internal/Checker/ConditionalChecker.php | 153 ++++++++++ src/Internal/Checker/ParamChecker.php | 43 ++- src/Internal/Checker/ReturnChecker.php | 134 +-------- src/Internal/Docblock/DocblockParser.php | 20 ++ .../CallableAndClosureContractsTest.php | 3 +- .../ConditionalParametersTest.php | 284 ++++++++++++++++++ 6 files changed, 498 insertions(+), 139 deletions(-) create mode 100644 src/Internal/Checker/ConditionalChecker.php create mode 100644 tests/TypeChecking/Conditionals/ConditionalParametersTest.php diff --git a/src/Internal/Checker/ConditionalChecker.php b/src/Internal/Checker/ConditionalChecker.php new file mode 100644 index 00000000..fdeda3aa --- /dev/null +++ b/src/Internal/Checker/ConditionalChecker.php @@ -0,0 +1,153 @@ + $vars + * @param array $boundTemplates + */ + public static function resolve( + TypeNode $typeNode, + array $vars, + array $boundTemplates, + TypeValidatorRegistry $registry, + string $function = '' + ): TypeNode { + if ($typeNode instanceof ConditionalTypeForParameterNode) { + return self::resolveParameterConditional($typeNode, $vars, $boundTemplates, $registry, $function); + } + + if ($typeNode instanceof ConditionalTypeNode) { + return self::resolveTemplateConditional($typeNode, $vars, $boundTemplates, $registry, $function); + } + + return $typeNode; + } + + /** + * Resolves parameter-based conditional types ($param is Target ? If : Else). + * + * @param array $vars + * @param array $boundTemplates + */ + public static function resolveParameterConditional( + ConditionalTypeForParameterNode $node, + array $vars, + array $boundTemplates, + TypeValidatorRegistry $registry, + string $function = '' + ): TypeNode { + $paramName = ltrim($node->parameterName, '$'); + $paramValue = null; + + if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { + $paramValue = $vars[$paramName]; + } elseif (\count($vars) > 0 && $function !== '' && str_contains($function, '::')) { + $paramValue = self::resolveRenamedParamValue($function, $paramName, $vars); + } + + $targetErr = $registry->validate($paramValue, $node->targetType, 'condition'); + $isTargetMatch = ($targetErr === null); + if ($node->negated) { + $isTargetMatch = ! $isTargetMatch; + } + + $selectedBranch = $isTargetMatch ? $node->if : $node->else; + + return self::resolve($selectedBranch, $vars, $boundTemplates, $registry, $function); + } + + /** + * Resolves template-based conditional types (T is Target ? If : Else). + * + * @param array $vars + * @param array $boundTemplates + */ + public static function resolveTemplateConditional( + ConditionalTypeNode $node, + array $vars, + array $boundTemplates, + TypeValidatorRegistry $registry, + string $function = '' + ): TypeNode { + $subjectTypeNode = $node->subjectType; + if ($subjectTypeNode instanceof IdentifierTypeNode && isset($boundTemplates[$subjectTypeNode->name])) { + $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; + } + + $isTargetMatch = TemplateManager::checkVariance($subjectTypeNode, $node->targetType, GenericTypeNode::VARIANCE_COVARIANT); + if ($node->negated) { + $isTargetMatch = ! $isTargetMatch; + } + + $selectedBranch = $isTargetMatch ? $node->if : $node->else; + + return self::resolve($selectedBranch, $vars, $boundTemplates, $registry, $function); + } + + /** + * Disambiguates parameter value by positional index in method hierarchy when renamed in child class. + * + * @param array $vars + */ + private static function resolveRenamedParamValue(string $function, string $paramName, array $vars): mixed + { + [$className, $methodName] = explode('::', $function, 2); + if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { + return null; + } + + try { + /** @var class-string $className */ + $refClass = new ReflectionClass($className); + if (! $refClass->hasMethod($methodName)) { + return null; + } + + $refMethod = $refClass->getMethod($methodName); + $hierarchy = HierarchyResolver::getMethodHierarchy($refMethod); + + $targetIndex = null; + foreach ($hierarchy as $hierMethod) { + foreach ($hierMethod->getParameters() as $idx => $p) { + if ($p->getName() === $paramName) { + $targetIndex = $idx; + + break 2; + } + } + } + + if ($targetIndex !== null) { + $values = array_values($vars); + if (isset($values[$targetIndex]) || \array_key_exists($targetIndex, $values)) { + return $values[$targetIndex]; + } + } + } catch (Throwable $e) { + // Silently ignore reflection errors + } + + return null; + } +} diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 098f1cb8..e97981af 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -7,6 +7,8 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; @@ -209,6 +211,13 @@ private static function validateSimpleParams( ): ?ErrorMessage { foreach ($types as $paramName => $typeNode) { if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { + if ( + $typeNode instanceof ConditionalTypeForParameterNode || + $typeNode instanceof ConditionalTypeNode + ) { + $typeNode = ConditionalChecker::resolve($typeNode, $vars, [], $registry, $effectiveFunction); + } + if (self::isUnconstrained($typeNode)) { continue; } @@ -307,6 +316,10 @@ private static function validateAllParameters( continue; } + $currentBoundTemplates = (\count($allTemplates) > 0) + ? TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates) + : $boundTemplates; + $err = self::validateSingleParam( $paramName, $baseTypes[$paramName], @@ -315,10 +328,11 @@ private static function validateAllParameters( $thisObj, $allTemplates, $aliases, - $boundTemplates, + $currentBoundTemplates, $declaredTemplates, $registry, - $classTemplates + $classTemplates, + $vars ); if ($err !== null) { @@ -738,7 +752,9 @@ private static function bindTemplateIfUnbound( * @param array $aliases * @param array $boundTemplates * @param array $declaredTemplates + * @param TypeValidatorRegistry $registry * @param array $classTemplates + * @param array $vars */ private static function validateSingleParam( string $paramName, @@ -751,8 +767,16 @@ private static function validateSingleParam( array $boundTemplates, array $declaredTemplates, TypeValidatorRegistry $registry, - array $classTemplates = [] + array $classTemplates = [], + array $vars = [] ): ?ErrorMessage { + if ( + $typeNode instanceof ConditionalTypeForParameterNode || + $typeNode instanceof ConditionalTypeNode + ) { + $typeNode = ConditionalChecker::resolve($typeNode, $vars, $boundTemplates, $registry, $effectiveFunction); + } + if (self::isUnconstrained($typeNode)) { return null; } @@ -766,6 +790,13 @@ private static function validateSingleParam( $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); } + if ( + $typeNode instanceof ConditionalTypeForParameterNode || + $typeNode instanceof ConditionalTypeNode + ) { + $typeNode = ConditionalChecker::resolve($typeNode, $vars, $boundTemplates, $registry, $effectiveFunction); + } + if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $classTemplates); } @@ -825,7 +856,9 @@ private static function validateMagicArguments( $aliases, $boundTemplates, $declaredTemplates, - $registry + $registry, + [], + $args ); if ($err !== null) { @@ -1251,7 +1284,7 @@ private static function tryWidenTemplate( return null; } - return $boundErr; + return $originalError; } /** diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 620de13e..9da7ec1e 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -7,17 +7,14 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; -use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; -use ReflectionClass; use Traversable; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; -use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -344,7 +341,7 @@ private static function evaluateReturn( $resolvedType = SpecialTypeResolver::resolve($resolvedType, $function, $thisObj); } - $resolvedType = self::resolveConditionalReturnType($resolvedType, $vars, $boundTemplates, $registry, $function); + $resolvedType = ConditionalChecker::resolve($resolvedType, $vars, $boundTemplates, $registry, $function); if ($cacheKey !== null) { self::$substitutedReturnCache[$cacheKey] = $resolvedType; @@ -384,133 +381,4 @@ private static function evaluateReturn( return $value; } - - /** - * Recursively resolves multi-branch nested conditional return types. - * - * @param array $vars - * @param array $boundTemplates - */ - private static function resolveConditionalReturnType( - TypeNode $returnTypeNode, - array $vars, - array $boundTemplates, - TypeValidatorRegistry $registry, - string $function = '' - ): TypeNode { - if ($returnTypeNode instanceof ConditionalTypeForParameterNode) { - return self::resolveParameterConditional($returnTypeNode, $vars, $boundTemplates, $registry, $function); - } - - if ($returnTypeNode instanceof ConditionalTypeNode) { - return self::resolveTemplateConditional($returnTypeNode, $vars, $boundTemplates, $registry); - } - - return $returnTypeNode; - } - - /** - * Resolves parameter-based conditional return types ($param is Target ? If : Else). - * - * @param array $vars - * @param array $boundTemplates - */ - private static function resolveParameterConditional( - ConditionalTypeForParameterNode $node, - array $vars, - array $boundTemplates, - TypeValidatorRegistry $registry, - string $function = '' - ): TypeNode { - $paramName = ltrim($node->parameterName, '$'); - $paramValue = null; - if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { - $paramValue = $vars[$paramName]; - } elseif (\count($vars) > 0 && $function !== '' && str_contains($function, '::')) { - $paramValue = self::resolveRenamedParamValue($function, $paramName, $vars); - } - - $targetErr = $registry->validate($paramValue, $node->targetType, 'condition'); - $isTargetMatch = ($targetErr === null); - if ($node->negated) { - $isTargetMatch = ! $isTargetMatch; - } - - $selectedBranch = $isTargetMatch ? $node->if : $node->else; - - return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry, $function); - } - - /** - * Disambiguates parameter value by positional index in method hierarchy when renamed in child class. - * - * @param array $vars - */ - private static function resolveRenamedParamValue(string $function, string $paramName, array $vars): mixed - { - [$className, $methodName] = explode('::', $function, 2); - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { - return null; - } - - try { - /** @var class-string $className */ - $refClass = new ReflectionClass($className); - if (! $refClass->hasMethod($methodName)) { - return null; - } - - $refMethod = $refClass->getMethod($methodName); - $hierarchy = HierarchyResolver::getMethodHierarchy($refMethod); - - $targetIndex = null; - foreach ($hierarchy as $hierMethod) { - foreach ($hierMethod->getParameters() as $idx => $p) { - if ($p->getName() === $paramName) { - $targetIndex = $idx; - - break 2; - } - } - } - - if ($targetIndex !== null) { - $values = array_values($vars); - if (isset($values[$targetIndex]) || \array_key_exists($targetIndex, $values)) { - return $values[$targetIndex]; - } - } - } catch (\Throwable $e) { - // Silently ignore reflection errors - } - - return null; - } - - /** - * Resolves template-based conditional return types (T is Target ? If : Else). - * - * @param array $vars - * @param array $boundTemplates - */ - private static function resolveTemplateConditional( - ConditionalTypeNode $node, - array $vars, - array $boundTemplates, - TypeValidatorRegistry $registry - ): TypeNode { - $subjectTypeNode = $node->subjectType; - if ($subjectTypeNode instanceof IdentifierTypeNode && isset($boundTemplates[$subjectTypeNode->name])) { - $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; - } - - $isTargetMatch = TemplateManager::checkVariance($subjectTypeNode, $node->targetType, GenericTypeNode::VARIANCE_COVARIANT); - if ($node->negated) { - $isTargetMatch = ! $isTargetMatch; - } - - $selectedBranch = $isTargetMatch ? $node->if : $node->else; - - return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); - } } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index a29df9e3..d6c5ab32 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -12,6 +12,8 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; @@ -189,6 +191,24 @@ public static function typeReferencesTemplate(?TypeNode $node, array $templateNa return false; } + if ($node instanceof ConditionalTypeNode) { + return self::typeReferencesTemplate($node->subjectType, $templateNames) + || self::typeReferencesTemplate($node->targetType, $templateNames) + || self::typeReferencesTemplate($node->if, $templateNames) + || self::typeReferencesTemplate($node->else, $templateNames); + } + + if ($node instanceof ConditionalTypeForParameterNode) { + return self::typeReferencesTemplate($node->targetType, $templateNames) + || self::typeReferencesTemplate($node->if, $templateNames) + || self::typeReferencesTemplate($node->else, $templateNames); + } + + if ($node instanceof OffsetAccessTypeNode) { + return self::typeReferencesTemplate($node->type, $templateNames) + || self::typeReferencesTemplate($node->offset, $templateNames); + } + if ($node instanceof ArrayTypeNode || $node instanceof NullableTypeNode) { return self::typeReferencesTemplate($node->type, $templateNames); } diff --git a/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php index 4d5766ad..1bc07455 100644 --- a/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php +++ b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php @@ -302,6 +302,7 @@ function testIntersectionCallableParam(callable $processor, object $collection): $nonStaticClosure = fn (int $id): string => "bound_{$id}"; expect(fn () => testStaticClosureParam($nonStaticClosure)) - ->toThrow(TypeError::class, 'must be a static Closure'); + ->toThrow(TypeError::class, 'must be a static Closure') + ; }); }); diff --git a/tests/TypeChecking/Conditionals/ConditionalParametersTest.php b/tests/TypeChecking/Conditionals/ConditionalParametersTest.php new file mode 100644 index 00000000..65da880e --- /dev/null +++ b/tests/TypeChecking/Conditionals/ConditionalParametersTest.php @@ -0,0 +1,284 @@ + : non-empty-string) $payload + */ +function tddFormatParamConditional(string $format, mixed $payload): mixed +{ + return $payload; +} + +/** + * 3. Negated parameter conditional ($mode is not 'raw') + * + * @param ($mode is not 'raw' ? array{id: positive-int} : string) $data + */ +function tddNegatedParamConditional(string $mode, mixed $data): mixed +{ + return $data; +} + +/** + * 4. Multi-branch 4-level nested parameter conditional + * + * @param ($type is 'int' ? positive-int : ($type is 'float' ? positive-float : ($type is 'bool' ? bool : non-empty-string))) $val + */ +function tddMultiBranchParamConditional(string $type, mixed $val): mixed +{ + return $val; +} + +/** + * 5. Generics inside conditional parameter (T is Dog ? list : T) + * + * @template T of Animal + * + * @param T $animal + * @param (T is Dog ? list : T) $output + */ +function tddGenericConditionalParam(Animal $animal, mixed $output): mixed +{ + return $output; +} + +/** + * 6. Union inside conditional branch ($asScalar is true ? (positive-int|non-empty-string) : array{id: positive-int}) + * + * @param ($asScalar is true ? (positive-int|non-empty-string) : array{id: positive-int}) $item + */ +function tddUnionParamConditional(bool $asScalar, mixed $item): mixed +{ + return $item; +} + +/** + * 7. Intersection inside conditional branch ($mode is 'complex' ? (Countable&ArrayAccess) : Countable) + * + * @param ($mode is 'complex' ? (Countable&ArrayAccess) : Countable) $collection + */ +function tddIntersectionParamConditional(string $mode, object $collection): object +{ + return $collection; +} + +/** + * 8. Parameter conditional on method with default argument + */ +class FixtureConditionalParamService +{ + /** + * @param ($strict is true ? positive-int : int) $code + */ + public function executeAction(int $code, bool $strict = false): int + { + return $code; + } +} + +describe('Conditional Parameter Contracts (@param ($condition ? A : B))', function () { + describe('1. Basic Parameter Conditionals', function () { + test('validates positive-int when asInt is true', function () { + expect(tddBasicParamConditional(true, 42))->toBe(42); + + expect(fn () => tddBasicParamConditional(true, -5)) + ->toThrow(TypeError::class, 'positive-int') + ; + + expect(fn () => tddBasicParamConditional(true, 'not_an_int')) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('validates non-empty-string when asInt is false', function () { + expect(tddBasicParamConditional(false, 'active_user'))->toBe('active_user'); + + expect(fn () => tddBasicParamConditional(false, '')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + + expect(fn () => tddBasicParamConditional(false, 123)) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + }); + + describe('2. String Literal / Format Discriminators', function () { + test('enforces array payload when format is json', function () { + expect(tddFormatParamConditional('json', ['status' => 'ok']))->toBe(['status' => 'ok']); + + expect(fn () => tddFormatParamConditional('json', '')) + ->toThrow(TypeError::class, 'array') + ; + }); + + test('enforces string payload when format is xml', function () { + expect(tddFormatParamConditional('xml', ''))->toBe(''); + + expect(fn () => tddFormatParamConditional('xml', '')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + + expect(fn () => tddFormatParamConditional('xml', ['status' => 'ok'])) + ->toThrow(TypeError::class, 'string') + ; + }); + }); + + describe('3. Negated Parameter Conditionals ($mode is not "raw")', function () { + test('enforces array shape when mode is not raw', function () { + expect(tddNegatedParamConditional('structured', ['id' => 10]))->toBe(['id' => 10]); + + expect(fn () => tddNegatedParamConditional('structured', ['id' => -10])) + ->toThrow(TypeError::class, 'positive-int') + ; + + expect(fn () => tddNegatedParamConditional('structured', 'raw_string')) + ->toThrow(TypeError::class, 'array') + ; + }); + + test('enforces string when mode is raw', function () { + expect(tddNegatedParamConditional('raw', 'raw_binary_data'))->toBe('raw_binary_data'); + + expect(fn () => tddNegatedParamConditional('raw', 12345)) + ->toThrow(TypeError::class, 'string') + ; + }); + }); + + describe('4. Multi-Branch Nested Parameter Conditionals', function () { + test('validates positive-int for type int', function () { + expect(tddMultiBranchParamConditional('int', 100))->toBe(100); + + expect(fn () => tddMultiBranchParamConditional('int', -10)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('validates positive-float for type float', function () { + expect(tddMultiBranchParamConditional('float', 3.14))->toBe(3.14); + + expect(fn () => tddMultiBranchParamConditional('float', -2.5)) + ->toThrow(TypeError::class, 'positive-float') + ; + }); + + test('validates bool for type bool', function () { + expect(tddMultiBranchParamConditional('bool', true))->toBeTrue(); + + expect(fn () => tddMultiBranchParamConditional('bool', 'not_a_bool')) + ->toThrow(TypeError::class, 'bool') + ; + }); + + test('validates non-empty-string fallback for unknown type', function () { + expect(tddMultiBranchParamConditional('custom', 'valid_text'))->toBe('valid_text'); + + expect(fn () => tddMultiBranchParamConditional('custom', '')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + }); + + describe('5. Generics Combined with Conditional Parameters', function () { + test('enforces list when animal is Dog', function () { + $dog = new Dog(); + $dogList = [$dog, new Dog()]; + + expect(tddGenericConditionalParam($dog, $dogList))->toBe($dogList); + + expect(fn () => tddGenericConditionalParam($dog, $dog)) + ->toThrow(TypeError::class, 'list') + ; + }); + + test('enforces single Cat instance when animal is Cat', function () { + $cat = new Cat(); + + expect(tddGenericConditionalParam($cat, $cat))->toBe($cat); + + expect(fn () => tddGenericConditionalParam($cat, [$cat])) + ->toThrow(TypeError::class, Cat::class) + ; + }); + }); + + describe('6. Unions in Conditional Branches', function () { + test('accepts positive-int or non-empty-string when asScalar is true', function () { + expect(tddUnionParamConditional(true, 10))->toBe(10); + expect(tddUnionParamConditional(true, 'code_10'))->toBe('code_10'); + + expect(fn () => tddUnionParamConditional(true, -5)) + ->toThrow(TypeError::class, '(positive-int | non-empty-string)') + ; + }); + + test('enforces array shape when asScalar is false', function () { + expect(tddUnionParamConditional(false, ['id' => 10]))->toBe(['id' => 10]); + + expect(fn () => tddUnionParamConditional(false, ['id' => -10])) + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; + }); + }); + + describe('7. Intersections in Conditional Branches', function () { + test('enforces Countable & ArrayAccess when mode is complex', function () { + $both = new CountableArrayAccess(); + expect(tddIntersectionParamConditional('complex', $both))->toBe($both); + + $onlyCountable = new CountableOnly(); + expect(fn () => tddIntersectionParamConditional('complex', $onlyCountable)) + ->toThrow(TypeError::class, '(Countable & ArrayAccess)') + ; + }); + + test('enforces Countable only when mode is simple', function () { + $onlyCountable = new CountableOnly(); + expect(tddIntersectionParamConditional('simple', $onlyCountable))->toBe($onlyCountable); + }); + }); + + describe('8. PHP 8.0+ Named Arguments in Swapped Order', function () { + test('resolves conditional parameter when passed in swapped order by name', function () { + expect(tddFormatParamConditional(payload: ['key' => 'val'], format: 'json'))->toBe(['key' => 'val']); + expect(tddFormatParamConditional(payload: 'text', format: 'xml'))->toBe('text'); + + expect(fn () => tddFormatParamConditional(payload: 'text', format: 'json')) + ->toThrow(TypeError::class, 'array') + ; + }); + }); + + describe('9. Class Methods with Default Arguments', function () { + test('evaluates condition against default argument when omitted', function () { + $service = new FixtureConditionalParamService(); + + expect($service->executeAction(-50))->toBe(-50); + expect(fn () => $service->executeAction(-50, strict: true)) + ->toThrow(TypeError::class, 'positive-int'); + }); + }); +}); From 9246bc59f96857376b8072a00dc03ccf4f029f3c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 15 Sep 2026 13:26:02 +0800 Subject: [PATCH 2/4] Add key-of and value-of shape extraction tests for array shapes --- src/Internal/Validator/GenericValidator.php | 84 +++++- .../KeyOfValueOfShapeExtractionTest.php | 263 ++++++++++++++++++ 2 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 tests/TypeChecking/ArraysAndShapes/KeyOfValueOfShapeExtractionTest.php diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index cbdffe7d..46879f6d 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\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; @@ -26,6 +27,25 @@ */ final class GenericValidator implements TypeValidatorInterface { + private const BUILTIN_GENERICS = [ + 'int' => true, + 'integer' => true, + 'class-string' => true, + 'list' => true, + 'non-empty-list' => true, + 'non-empty-array-list' => true, + 'array' => true, + 'non-empty-array' => true, + 'iterable' => true, + 'traversable' => true, + 'generator' => true, + 'iterator' => true, + 'key-of' => true, + 'value-of' => true, + 'int-mask' => true, + 'int-mask-of' => true, + ]; + /** * @var array */ @@ -55,8 +75,8 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'class-string' => $this->validateClassString($value, $genericNode, $context), 'list', 'non-empty-list', 'non-empty-array-list' => $this->validateList($value, $genericNode, $context, $registry), 'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry), - 'key-of' => $this->validateKeyOf($value, $genericNode, $context), - 'value-of' => $this->validateValueOf($value, $genericNode, $context), + 'key-of' => $this->validateKeyOf($value, $genericNode, $context, $registry), + 'value-of' => $this->validateValueOf($value, $genericNode, $context, $registry), 'int-mask' => $this->validateIntMask($value, $genericNode, $context), 'int-mask-of' => $this->validateIntMaskOf($value, $genericNode, $context), default => $this->validateObjectGeneric($value, $genericNode, $context), @@ -97,10 +117,32 @@ private function resolveConstantValue(string $fqcn, string $constName): mixed /** * Validates key-of generic structures with O(1) in-memory caching. */ - private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { $targetType = $node->genericTypes[0] ?? 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; + } + } + } + } + if (! \in_array($value, $validKeys, strict: true)) { + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + } + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { $constExpr = $targetType->constExpr; $fqcn = $constExpr->className; @@ -160,10 +202,30 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont 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; + } + /** * Validates value-of generic structures with O(1) in-memory caching. */ - private function validateValueOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateValueOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { $targetType = $node->genericTypes[0] ?? null; @@ -199,6 +261,14 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); } + } elseif ($targetType instanceof ArrayShapeNode) { + foreach ($targetType->items as $item) { + if ($registry->validate($value, $item->valueType, '') === null) { + return null; + } + } + + return ErrorFactory::createError($context . ' must be a value of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); } return null; @@ -413,7 +483,7 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return null; } - $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valueTypeNode->type->name)])); if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $sampleIndices = [0, $count - 1]; @@ -482,7 +552,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont return null; } - $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + $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]]; @@ -525,7 +595,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont return null; } - $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); diff --git a/tests/TypeChecking/ArraysAndShapes/KeyOfValueOfShapeExtractionTest.php b/tests/TypeChecking/ArraysAndShapes/KeyOfValueOfShapeExtractionTest.php new file mode 100644 index 00000000..06128857 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/KeyOfValueOfShapeExtractionTest.php @@ -0,0 +1,263 @@ + on array shapes + * + * @param key-of $field + */ +function tddSelectField(string $field): string +{ + return $field; +} + +/** + * 2. Basic value-of on array shapes + * + * @param value-of $value + */ +function tddSetFieldValue(mixed $value): mixed +{ + return $value; +} + +/** + * 3. key-of with literal union shapes + * + * @param key-of $key + */ +function tddConfigKey(string $key): string +{ + return $key; +} + +/** + * 4. value-of with literal union values + * + * @param value-of $status + */ +function tddSetStatus(string $status): string +{ + return $status; +} + +/** + * 5. Generic array key-of and value-of + * + * @template T + * + * @param array $map + * @param key-of> $key + * + * @return T|null + */ +function tddGetFromMap(array $map, string $key): mixed +{ + return $map[$key] ?? null; +} + +/** + * @template T + * + * @param value-of> $value + */ +function tddSetGenericValue(mixed $value): mixed +{ + return $value; +} + +/** + * 6. key-of as return type + * + * @return key-of + */ +function tddPickField(string $field): string +{ + return $field; +} + +/** + * 7. value-of as return type + * + * @return value-of + */ +function tddPickValue(mixed $value): mixed +{ + return $value; +} + +/** + * 8. Containers holding key-of / value-of + * + * @param list> $keys + */ +function tddSetKeys(array $keys): array +{ + return $keys; +} + +/** + * @param array{ + * field: key-of, + * value: value-of + * } $pair + */ +function tddSetPair(array $pair): array +{ + return $pair; +} + +/** + * 9. Deep nested extraction (key-of>) + * + * @param key-of> $key + */ +function tddNestedKey(string $key): string +{ + return $key; +} + +describe('key-of and value-of Shape Extractions', function () { + describe('1. Basic key-of from Array Shapes', function () { + test('accepts valid declared keys', function () { + expect(tddSelectField('name'))->toBe('name'); + expect(tddSelectField('age'))->toBe('age'); + expect(tddSelectField('email'))->toBe('email'); + }); + + test('rejects undeclared keys and case mismatches', function () { + expect(fn () => tddSelectField('unknown')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + + expect(fn () => tddSelectField('Name')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + }); + }); + + describe('2. Basic value-of from Array Shapes', function () { + test('accepts valid values satisfying any field in the shape', function () { + expect(tddSetFieldValue('Alice'))->toBe('Alice'); + expect(tddSetFieldValue(30))->toBe(30); + expect(tddSetFieldValue(true))->toBeTrue(); + }); + + test('rejects types not matching any field in the shape', function () { + expect(fn () => tddSetFieldValue(3.14)) + ->toThrow(TypeError::class, 'must be a value of the specified array shape') + ; + + expect(fn () => tddSetFieldValue(null)) + ->toThrow(TypeError::class, 'must be a value of the specified array shape') + ; + }); + }); + + describe('3. key-of with Literal Unions in Shapes', function () { + test('accepts valid shape keys', function () { + expect(tddConfigKey('gateway'))->toBe('gateway'); + expect(tddConfigKey('currency'))->toBe('currency'); + }); + + test('rejects unknown key', function () { + expect(fn () => tddConfigKey('other')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + }); + }); + + describe('4. value-of with Literal Unions in Shapes', function () { + test('accepts declared literal union values', function () { + expect(tddSetStatus('draft'))->toBe('draft'); + expect(tddSetStatus('published'))->toBe('published'); + expect(tddSetStatus('archived'))->toBe('archived'); + }); + + test('rejects value not present in the union', function () { + expect(fn () => tddSetStatus('deleted')) + ->toThrow(TypeError::class, 'must be a value of the specified array shape') + ; + }); + }); + + describe('5. key-of and value-of with Generic Arrays', function () { + test('allows string keys for key-of>', function () { + $user = new FixtureKeyOfUser('Alice'); + expect(tddGetFromMap(['a' => $user], 'a'))->toBe($user); + expect(tddGetFromMap(['a' => $user], 'b'))->toBeNull(); + }); + + test('allows generic values for value-of>', function () { + expect(tddSetGenericValue('hello'))->toBe('hello'); + expect(tddSetGenericValue(3.14))->toBe(3.14); + }); + }); + + describe('6. key-of and value-of as Return Types', function () { + test('validates key-of return types', function () { + expect(tddPickField('name'))->toBe('name'); + + expect(fn () => tddPickField('unknown')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + }); + + test('validates value-of return types', function () { + expect(tddPickValue('Alice'))->toBe('Alice'); + + expect(fn () => tddPickValue('')) + ->toThrow(TypeError::class, 'must be a value of the specified array shape') + ; + }); + }); + + describe('7. key-of and value-of inside Containers (Lists & Shapes)', function () { + test('validates list>', function () { + expect(tddSetKeys(['a', 'b']))->toBe(['a', 'b']); + + expect(fn () => tddSetKeys(['a', 'c'])) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + }); + + test('validates shapes combining key-of and value-of', function () { + expect(tddSetPair(['field' => 'name', 'value' => 'Alice']))->toBe(['field' => 'name', 'value' => 'Alice']); + + expect(fn () => tddSetPair(['field' => 'unknown', 'value' => 'Alice'])) + ->toThrow(TypeError::class, "['field'] must be a key of the specified array shape") + ; + + expect(fn () => tddSetPair(['field' => 'name', 'value' => 3.14])) + ->toThrow(TypeError::class, "['value'] must be a value of the specified array shape") + ; + }); + }); + + describe('8. Deep Nested Extractions (key-of>)', function () { + test('extracts keys from nested inner array shapes', function () { + expect(tddNestedKey('host'))->toBe('host'); + expect(tddNestedKey('port'))->toBe('port'); + }); + + test('rejects outer keys not belonging to inner value shapes', function () { + expect(fn () => tddNestedKey('other')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + + expect(fn () => tddNestedKey('config')) + ->toThrow(TypeError::class, 'must be a key of the specified array shape') + ; + }); + }); +}); From aae5891d434d647d586dfce00328c8a48f9fe8da Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 15 Sep 2026 13:53:04 +0800 Subject: [PATCH 3/4] Add cache_check_mtime configuration and update CacheManager to respect it; enhance tests for cache key generation --- src/Internal/Cli/ConfigInitCommand.php | 13 +++++++++++++ src/Internal/Io/CacheManager.php | 10 +++++++--- src/Internal/Util/Config.php | 14 ++++++++++++++ tests/Internal/Io/CacheManagerTest.php | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index 9fbc66ce..e58cb88f 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -153,6 +153,19 @@ private static function getTemplate(): string 'cache' => true, 'cache_dir' => null, + /* + |-------------------------------------------------------------------------- + | Cache File Modification Monitor + |-------------------------------------------------------------------------- + | When enabled (default), TypePHP checks file modification times (filemtime) + | to automatically rebuild the cache when a file changes. + | + | In production, files do not change. Set this to FALSE to eliminate + | hundreds of disk I/O checks per request for maximum performance. + | Note: If disabled, you must run `php bin/typephp cache:clear` on deployment. + */ + 'cache_check_mtime' => true, + /* |-------------------------------------------------------------------------- | Registered Extensions diff --git a/src/Internal/Io/CacheManager.php b/src/Internal/Io/CacheManager.php index c067495f..5af4dd0f 100644 --- a/src/Internal/Io/CacheManager.php +++ b/src/Internal/Io/CacheManager.php @@ -72,10 +72,14 @@ public static function getCacheDir(): string */ public static function getCacheKey(string $resolvedPath): string { - $mtime = @filemtime($resolvedPath); - $mtimeStr = $mtime !== false ? (string) $mtime : '0'; + if (Config::isCacheCheckMtimeEnabled()) { + $mtime = @filemtime($resolvedPath); + $mtimeStr = $mtime !== false ? (string) $mtime : '0'; - return hash('xxh128', self::VERSION_PREFIX . $resolvedPath . $mtimeStr); + return hash('xxh128', self::VERSION_PREFIX . $resolvedPath . $mtimeStr); + } + + return hash('xxh128', self::VERSION_PREFIX . $resolvedPath); } /** diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 94adb2ad..2c494158 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -56,6 +56,8 @@ final class Config private static bool $vendorBoundaryOnly = true; + private static bool $cacheCheckMtime = true; + private static string $arrayValidation = 'full'; public static function isEnabled(): bool @@ -67,6 +69,15 @@ public static function isEnabled(): bool return self::$enabled; } + public static function isCacheCheckMtimeEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$cacheCheckMtime; + } + public static function isParamsEnabled(): bool { if (self::$cachedConfig === null) { @@ -236,6 +247,7 @@ public static function get(): array 'array_validation' => 'full', 'cache' => true, 'cache_dir' => null, + 'cache_check_mtime' => true, 'inline_vars' => [ 'properties' => true, 'generics' => true, @@ -376,6 +388,7 @@ public static function reset(): void self::$respectNativeNullability = true; self::$vendorBoundaryOnly = true; self::$arrayValidation = 'full'; + self::$cacheCheckMtime = true; DocblockParser::reset(); ParamChecker::reset(); @@ -410,5 +423,6 @@ private static function syncFlags(array $config): void self::$respectNativeNullability = (bool) ($config['respect_native_nullability'] ?? true); self::$vendorBoundaryOnly = (bool) ($config['vendor_boundary_only'] ?? true); self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full'; + self::$cacheCheckMtime = (bool) ($config['cache_check_mtime'] ?? true); } } diff --git a/tests/Internal/Io/CacheManagerTest.php b/tests/Internal/Io/CacheManagerTest.php index 5ce01a64..404e54c6 100644 --- a/tests/Internal/Io/CacheManagerTest.php +++ b/tests/Internal/Io/CacheManagerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use TypePHP\Internal\Io\CacheManager; +use TypePHP\Internal\Util\Config; describe('CacheManager Unit Tests', function () { test('returns valid cache directory path', function () { @@ -29,4 +30,21 @@ ->and(file_exists($testFile))->toBeFalse() ; }); + + test('bypasses filemtime when cache_check_mtime is disabled', function () { + try { + $file = __FILE__; + + Config::set(['cache_check_mtime' => true]); + $keyWithMtime = CacheManager::getCacheKey($file); + + Config::set(['cache_check_mtime' => false]); + $keyWithoutMtime = CacheManager::getCacheKey($file); + + expect($keyWithMtime)->not()->toBe($keyWithoutMtime); + expect(CacheManager::getCacheKey($file))->toBe($keyWithoutMtime); + } finally { + Config::reset(); + } + }); }); From 25ce65d95fdee98ef0b01cd86e4c3bb8d7ba5474 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 15 Sep 2026 14:08:36 +0800 Subject: [PATCH 4/4] fix code styling --- tests/TypeChecking/Conditionals/ConditionalParametersTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/TypeChecking/Conditionals/ConditionalParametersTest.php b/tests/TypeChecking/Conditionals/ConditionalParametersTest.php index 65da880e..9ad3ecb9 100644 --- a/tests/TypeChecking/Conditionals/ConditionalParametersTest.php +++ b/tests/TypeChecking/Conditionals/ConditionalParametersTest.php @@ -278,7 +278,8 @@ public function executeAction(int $code, bool $strict = false): int expect($service->executeAction(-50))->toBe(-50); expect(fn () => $service->executeAction(-50, strict: true)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); }); });