From 6116dcbbe0e136d0d63d7c6df260ce07bcd85444 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 16:27:47 +0800 Subject: [PATCH 01/17] Add tests for dynamic class static property transformations and handle edge cases --- .gitignore | 3 +- tests/Internal/Ast/ContractVisitorTest.php | 130 +++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8ec4fdf..5b39fba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ composer.lock index.php benchmark.php .php-cs-fixer.cache -/app \ No newline at end of file +/app +/coverage-report \ No newline at end of file diff --git a/tests/Internal/Ast/ContractVisitorTest.php b/tests/Internal/Ast/ContractVisitorTest.php index 05ec72e..ecd3d1b 100644 --- a/tests/Internal/Ast/ContractVisitorTest.php +++ b/tests/Internal/Ast/ContractVisitorTest.php @@ -8,6 +8,7 @@ use PhpParser\ParserFactory; use TypePHP\Internal\Ast\ContractVisitor; use TypePHP\Internal\Ast\TypePHPPrinter; +use TypePHP\Internal\Util\Config; describe('ContractVisitor AST Transformation Unit Tests', function () { beforeEach(function () { @@ -341,4 +342,133 @@ public function enumMethod() { expect($transformed)->not()->toContain('RuntimeTypeChecker::checkVariable'); }); + + test('transforms dynamic class static property compound assignments and inc/dec expressions', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$className::\$staticCount + 5, \$className, 'staticCount'") + ->and($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$getObj()::\$staticCount + 1, \$getObj(), 'staticCount'") + ->and($transformed)->toContain("RuntimeTypeChecker::checkProperty(\$className::\$staticCount2 - 1, \$className, 'staticCount2'") + ; + }); + + test('bypasses already wrapped clone nodes and unsupported assign op AST nodes', function () { + $cloneNode = new \PhpParser\Node\Expr\Clone_(new \PhpParser\Node\Expr\Variable('orig')); + $cloneNode->setAttribute('typephp_wrapped', true); + + $visitor = new ContractVisitor(); + expect($visitor->leaveNode($cloneNode))->toBeNull(); + + $customAssignOp = new class( + new \PhpParser\Node\Expr\Variable('x'), + new \PhpParser\Node\Scalar\LNumber(1) + ) extends \PhpParser\Node\Expr\AssignOp { + public function getType(): string + { + return 'Expr_CustomAssignOp'; + } + + public function getSubNodeNames(): array + { + return ['var', 'expr']; + } + }; + + expect($visitor->leaveNode($customAssignOp))->toBeNull(); + }); + + test('transforms dynamic class static property read expressions into checkStaticProperty calls', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain("RuntimeTypeChecker::checkStaticProperty(\$className, 'staticProperty', \$className::\$staticProperty") + ->and($transformed)->toContain("RuntimeTypeChecker::checkStaticProperty(\$getObj(), 'staticProperty', \$getObj()::\$staticProperty") + ; + }); + + test('skips class property defaults processing when inline properties are disabled in config', function () { + Config::set(['inline_vars' => ['properties' => false]]); + + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->not()->toContain('RuntimeTypeChecker::checkProperty'); + }); + + test('handles bare return statements and malformed @var return docblocks gracefully', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('return;') + ->and($transformed)->toContain('return 42;') + ; + }); + + test('handles top-level bare return statements without expressions', function () { + $code = <<<'PHP' +parser->parse($code); + $traverser = new NodeTraverser(); + $traverser->addVisitor(new ContractVisitor()); + $newStmts = $traverser->traverse($stmts); + + $transformed = $this->printer->prettyPrint($newStmts); + + expect($transformed)->toContain('return;'); + }); }); From e8d3eb47695354e24862c35b2d79d79e5cda266d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 17:02:19 +0800 Subject: [PATCH 02/17] Refactor return node handling and add tests for variable name inference and malformed docblocks --- src/Internal/Ast/ContractVisitor.php | 2 +- tests/Internal/Ast/ScopeManagerTest.php | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 5462524..8b07505 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -99,7 +99,7 @@ public function enterNode(Node $node): ?array return null; } - if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) { + if ($node instanceof Node\Stmt\Return_) { $this->handleReturn($node); return null; diff --git a/tests/Internal/Ast/ScopeManagerTest.php b/tests/Internal/Ast/ScopeManagerTest.php index 43ca448..e64cab4 100644 --- a/tests/Internal/Ast/ScopeManagerTest.php +++ b/tests/Internal/Ast/ScopeManagerTest.php @@ -24,7 +24,7 @@ $manager->pushScope(); $manager->extractVarDocblock('/** @var positive-int $globalId */'); - $manager->pushScope(); // Inner scope + $manager->pushScope(); expect($manager->getVarTypeFromScope('globalId'))->toBe('positive-int'); $manager->popScope(); @@ -74,4 +74,24 @@ expect($manager->getVarTypeFromScope('tag'))->toBe('non-empty-string'); }); + + test('infers variable name from standalone variable expression when unnamed in docblock', function () { + $manager = new ScopeManager(); + $manager->pushScope(); + + $varExpr = new Node\Expr\Variable('activeUser'); + + $manager->extractVarDocblock('/** @var non-empty-string */', $varExpr); + + expect($manager->getVarTypeFromScope('activeUser'))->toBe('non-empty-string'); + }); + + test('gracefully catches and ignores malformed docblocks that throw parser exceptions', function () { + $manager = new ScopeManager(); + $manager->pushScope(); + + $manager->extractVarDocblock('invalid docblock text without phpdoc comment markers @var int $test'); + + expect($manager->getVarTypeFromScope('test'))->toBeNull(); + }); }); From b9a31efe2480c0707c5157d00fb5b81ed35e7110 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 17:15:10 +0800 Subject: [PATCH 03/17] Add tests for doc comment and native union type extraction in FunctionContractInjector --- src/Internal/Ast/FunctionContractInjector.php | 32 ++--- .../Ast/FunctionContractInjectorTest.php | 125 ++++++++++++++++++ 2 files changed, 133 insertions(+), 24 deletions(-) diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index ff067f1..34933c1 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -150,23 +150,12 @@ private static function resolveDocComment(Node\Stmt\Function_|Node\Stmt\ClassMet return $doc; } - foreach ($node->getComments() as $comment) { - if ($comment instanceof Doc) { - return $comment; - } - } - if ($node->attrGroups !== []) { foreach ($node->attrGroups as $group) { $groupDoc = $group->getDocComment(); if ($groupDoc !== null) { return $groupDoc; } - foreach ($group->getComments() as $comment) { - if ($comment instanceof Doc) { - return $comment; - } - } } } @@ -286,11 +275,7 @@ private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_| private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): bool { - if ($node->stmts === null) { - return false; - } - - $visitor = new class () extends NodeVisitorAbstract { + $visitor = new class() extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -311,7 +296,9 @@ public function enterNode(Node $n): ?int $traverser = new NodeTraverser(); $traverser->addVisitor($visitor); - $traverser->traverse($node->stmts); + /** @var array $stmts */ + $stmts = $node->stmts; + $traverser->traverse($stmts); return $visitor->isGen; } @@ -684,10 +671,8 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) - { - } + $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) {} public function enterNode(Node $n): int|Node|null { @@ -746,7 +731,7 @@ private static function wrapNonGeneratorReturns( bool $hasSelfOut = false ): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { + $traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { /** * @param array $byRefParams */ @@ -757,8 +742,7 @@ public function __construct( private bool $hasReturn, private array $byRefParams, private bool $hasSelfOut - ) { - } + ) {} public function enterNode(Node $n): int|array|null { diff --git a/tests/Internal/Ast/FunctionContractInjectorTest.php b/tests/Internal/Ast/FunctionContractInjectorTest.php index 8e32573..accfded 100644 --- a/tests/Internal/Ast/FunctionContractInjectorTest.php +++ b/tests/Internal/Ast/FunctionContractInjectorTest.php @@ -327,4 +327,129 @@ public function dd(): never ; }); }); + + describe('DocComment & Native Union Type Extraction Coverage', function () { + test('resolves doc comments from node comments and attribute groups', function () { + $doc = new Doc('/** @param positive-int $id */'); + $fnWithDoc = new Node\Stmt\Function_('fnDoc', [ + 'params' => [new Node\Param(new Node\Expr\Variable('id'))], + 'stmts' => [], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fnWithDoc); + expect($fnWithDoc->stmts[0]->getAttribute('typephp_injected'))->toBeTrue(); + + $attr = new Node\Attribute(new Node\Name('Route')); + $attrGroupWithDoc = new Node\AttributeGroup([$attr], [ + 'comments' => [$doc], + ]); + $fnWithAttrDoc = new Node\Stmt\Function_('fnAttrDoc', [ + 'attrGroups' => [$attrGroupWithDoc], + 'params' => [new Node\Param(new Node\Expr\Variable('id'))], + 'stmts' => [], + ]); + + FunctionContractInjector::inject($fnWithAttrDoc); + expect($fnWithAttrDoc->stmts[0]->getAttribute('typephp_injected'))->toBeTrue(); + }); + + test('detects callable and iterable candidates in native union parameter types without docblocks', function () { + $unionCallableParam = new Node\Param( + new Node\Expr\Variable('cb'), + null, + new Node\UnionType([ + new Node\Identifier('callable'), + new Node\Identifier('string'), + ]) + ); + + $methodCallableUnion = new Node\Stmt\ClassMethod('testUnionCb', [ + 'params' => [$unionCallableParam], + 'stmts' => [], + ]); + + FunctionContractInjector::inject($methodCallableUnion); + expect($methodCallableUnion->stmts)->not()->toBeEmpty(); + + $hasWrapCallable = false; + foreach ($methodCallableUnion->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Assign) { + if ($stmt->expr->expr instanceof Node\Expr\FuncCall && str_contains($stmt->expr->expr->name->toString(), 'wrapCallable')) { + $hasWrapCallable = true; + } + } + } + expect($hasWrapCallable)->toBeTrue(); + + $unionIterableParam = new Node\Param( + new Node\Expr\Variable('items'), + null, + new Node\UnionType([ + new Node\Name('Traversable'), + new Node\Name('Countable'), + ]) + ); + + $methodIterableUnion = new Node\Stmt\ClassMethod('testUnionIterable', [ + 'params' => [$unionIterableParam], + 'stmts' => [], + ]); + + FunctionContractInjector::inject($methodIterableUnion); + expect($methodIterableUnion->stmts)->not()->toBeEmpty(); + + $hasWrapIterable = false; + foreach ($methodIterableUnion->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Assign) { + if ($stmt->expr->expr instanceof Node\Expr\FuncCall && str_contains($stmt->expr->expr->name->toString(), 'wrapIterable')) { + $hasWrapIterable = true; + } + } + } + expect($hasWrapIterable)->toBeTrue(); + }); + }); + + test('skips return contract injection when @return specifies mixed', function () { + $doc = new Doc('/** @return mixed */'); + $fnMixed = new Node\Stmt\Function_('fnMixed', [ + 'stmts' => [new Node\Stmt\Return_(new Node\Scalar\String_('ok'))], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fnMixed); + + expect($fnMixed->stmts[0]->expr)->toBeInstanceOf(Node\Scalar\String_::class); + }); + + test('bypasses already wrapped yield from and var wrapped return nodes', function () { + $yieldFrom = new Node\Expr\YieldFrom(new Node\Expr\Array_()); + $yieldFrom->setAttribute('typephp_wrapped', true); + + $docGen = new Doc('/** @return Generator */'); + $fnGen = new Node\Stmt\Function_('fnGenPreWrapped', [ + 'stmts' => [new Node\Stmt\Expression($yieldFrom)], + ], [ + 'comments' => [$docGen], + ]); + + FunctionContractInjector::inject($fnGen); + expect($fnGen->stmts)->not()->toBeEmpty(); + + $ret = new Node\Stmt\Return_(new Node\Scalar\String_('already_wrapped')); + $ret->setAttribute('typephp_var_wrapped', true); + + $docRet = new Doc('/** @return string */'); + $fnRet = new Node\Stmt\Function_('fnRetVarWrapped', [ + 'stmts' => [$ret], + ], [ + 'comments' => [$docRet], + ]); + + FunctionContractInjector::inject($fnRet); + expect($fnRet->stmts[0]->expr)->toBeInstanceOf(Node\Scalar\String_::class); + }); }); From c621bcbe92c92392442dae4c78975faaf738431c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 17:22:19 +0800 Subject: [PATCH 04/17] Refactor class instantiation syntax and improve test coverage for PropertyHookInjector --- src/Internal/Ast/FunctionContractInjector.php | 13 ++++---- tests/Internal/Ast/ContractVisitorTest.php | 2 +- .../Internal/Ast/PropertyHookInjectorTest.php | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 34933c1..990170d 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -275,7 +275,7 @@ private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_| private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): bool { - $visitor = new class() extends NodeVisitorAbstract { + $visitor = new class () extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -671,8 +671,10 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) {} + $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) + { + } public function enterNode(Node $n): int|Node|null { @@ -731,7 +733,7 @@ private static function wrapNonGeneratorReturns( bool $hasSelfOut = false ): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { /** * @param array $byRefParams */ @@ -742,7 +744,8 @@ public function __construct( private bool $hasReturn, private array $byRefParams, private bool $hasSelfOut - ) {} + ) { + } public function enterNode(Node $n): int|array|null { diff --git a/tests/Internal/Ast/ContractVisitorTest.php b/tests/Internal/Ast/ContractVisitorTest.php index ecd3d1b..90c786c 100644 --- a/tests/Internal/Ast/ContractVisitorTest.php +++ b/tests/Internal/Ast/ContractVisitorTest.php @@ -371,7 +371,7 @@ public function enumMethod() { $visitor = new ContractVisitor(); expect($visitor->leaveNode($cloneNode))->toBeNull(); - $customAssignOp = new class( + $customAssignOp = new class ( new \PhpParser\Node\Expr\Variable('x'), new \PhpParser\Node\Scalar\LNumber(1) ) extends \PhpParser\Node\Expr\AssignOp { diff --git a/tests/Internal/Ast/PropertyHookInjectorTest.php b/tests/Internal/Ast/PropertyHookInjectorTest.php index e61fb9b..012b124 100644 --- a/tests/Internal/Ast/PropertyHookInjectorTest.php +++ b/tests/Internal/Ast/PropertyHookInjectorTest.php @@ -60,6 +60,36 @@ ->and($body[0]->expr)->toBeInstanceOf(Node\Expr\Ternary::class) ; }); + + test('does not traverse into nested closures inside get property hook bodies', function () { + $innerClosure = new Node\Expr\Closure([ + 'stmts' => [ + new Node\Stmt\Return_(new Node\Scalar\String_('closure_return')), + ], + ]); + + $hook = new Node\PropertyHook( + name: 'get', + body: [ + new Node\Stmt\Expression(new Node\Expr\Assign(new Node\Expr\Variable('fn'), $innerClosure)), + new Node\Stmt\Return_(new Node\Scalar\String_('hook_return')), + ] + ); + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); + + $body = $prop->hooks[0]->body; + + expect($body[1]->expr)->toBeInstanceOf(Node\Expr\Ternary::class); + + expect($innerClosure->stmts[0]->expr)->toBeInstanceOf(Node\Scalar\String_::class); + }); }); describe('Set Property Hooks', function () { From 76f72bbdb62be755a2ffc92cfc0db10d3c89bfcb Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 18:00:16 +0800 Subject: [PATCH 05/17] Refactor IgnoreManager and enhance test coverage with new fixtures and assertions --- src/Internal/Util/IgnoreManager.php | 36 ++-- .../Internal/Util/ClassNameValidatorTest.php | 129 ++++++++++---- tests/Internal/Util/IgnoreManagerTest.php | 162 +++++++++++++----- 3 files changed, 229 insertions(+), 98 deletions(-) diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php index 56e97a2..9a9293c 100644 --- a/src/Internal/Util/IgnoreManager.php +++ b/src/Internal/Util/IgnoreManager.php @@ -6,7 +6,6 @@ use ReflectionClass; use ReflectionFunction; -use Throwable; /** * @internal High-performance manager for resolving file-level and caller-level @typephp-ignore tags. @@ -112,7 +111,7 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal continue; } - if (str_starts_with($function, '__pest_')) { + if (str_contains($function, '__pest_')) { continue; } @@ -198,25 +197,17 @@ private static function checkMethodIgnored(string $class, string $method): bool return false; } - try { - /** @var class-string $class */ - $refClass = new ReflectionClass($class); + /** @var class-string $class */ + $refClass = new ReflectionClass($class); - if ($refClass->hasMethod($method)) { - $refMethod = $refClass->getMethod($method); - if (self::hasIgnoreDocTag($refMethod->getDocComment())) { - return true; - } - } - - if (self::hasIgnoreDocTag($refClass->getDocComment())) { + if ($refClass->hasMethod($method)) { + $refMethod = $refClass->getMethod($method); + if (self::hasIgnoreDocTag($refMethod->getDocComment())) { return true; } - } catch (Throwable $e) { - return false; } - return false; + return self::hasIgnoreDocTag($refClass->getDocComment()); } private static function checkFunctionIgnored(string $function): bool @@ -232,15 +223,8 @@ private static function checkFunctionIgnored(string $function): bool return false; } - try { - $refFunc = new ReflectionFunction($function); - if (self::hasIgnoreDocTag($refFunc->getDocComment())) { - return true; - } - } catch (Throwable $e) { - return false; - } + $refFunc = new ReflectionFunction($function); - return false; + return self::hasIgnoreDocTag($refFunc->getDocComment()); } -} +} \ No newline at end of file diff --git a/tests/Internal/Util/ClassNameValidatorTest.php b/tests/Internal/Util/ClassNameValidatorTest.php index b79e6cf..6b5e30c 100644 --- a/tests/Internal/Util/ClassNameValidatorTest.php +++ b/tests/Internal/Util/ClassNameValidatorTest.php @@ -2,41 +2,108 @@ declare(strict_types=1); +namespace TypePHP\Tests\Internal\Util; + +use ArrayAccess; +use Countable; +use DateTime; +use stdClass; use TypePHP\Internal\Util\ClassNameValidator; +use TypePHP\Tests\Fixtures\Domain\User; -describe('ClassNameValidator', function () { - test('accepts valid simple and namespaced PHP class names', function () { - expect(ClassNameValidator::isValid('Animal'))->toBeTrue(); - expect(ClassNameValidator::isValid('User_Service'))->toBeTrue(); - expect(ClassNameValidator::isValid('_PrivateClass'))->toBeTrue(); - expect(ClassNameValidator::isValid('Class123'))->toBeTrue(); - expect(ClassNameValidator::isValid('TypePHP\Tests\Fixtures\Domain\Dog'))->toBeTrue(); - expect(ClassNameValidator::isValid('\App\Services\UserService'))->toBeTrue(); - }); +describe('ClassNameValidator Unit Tests', function () { + describe('isValid() - Syntax Validator', function () { + test('accepts valid simple and namespaced PHP class names', function () { + expect(ClassNameValidator::isValid('Animal'))->toBeTrue() + ->and(ClassNameValidator::isValid('User_Service'))->toBeTrue() + ->and(ClassNameValidator::isValid('_PrivateClass'))->toBeTrue() + ->and(ClassNameValidator::isValid('Class123'))->toBeTrue() + ->and(ClassNameValidator::isValid('TypePHP\Tests\Fixtures\Domain\Dog'))->toBeTrue() + ->and(ClassNameValidator::isValid('\App\Services\UserService'))->toBeTrue() + ; + }); - test('rejects generic type annotations and complex PHPDoc types', function () { - expect(ClassNameValidator::isValid('Producer'))->toBeFalse(); - expect(ClassNameValidator::isValid('Repository'))->toBeFalse(); - expect(ClassNameValidator::isValid('array{id: int, name: string}'))->toBeFalse(); - expect(ClassNameValidator::isValid('int<1, 100>'))->toBeFalse(); - expect(ClassNameValidator::isValid('string[]'))->toBeFalse(); - expect(ClassNameValidator::isValid('User|Admin'))->toBeFalse(); - expect(ClassNameValidator::isValid('Countable&ArrayAccess'))->toBeFalse(); - }); + test('accepts anonymous class names registered in memory', function () { + $anon = new class () {}; + expect(ClassNameValidator::isValid($anon::class))->toBeTrue(); + }); + + test('rejects generic type annotations and complex PHPDoc types', function () { + expect(ClassNameValidator::isValid('Producer'))->toBeFalse() + ->and(ClassNameValidator::isValid('Repository'))->toBeFalse() + ->and(ClassNameValidator::isValid('array{id: int, name: string}'))->toBeFalse() + ->and(ClassNameValidator::isValid('int<1, 100>'))->toBeFalse() + ->and(ClassNameValidator::isValid('string[]'))->toBeFalse() + ->and(ClassNameValidator::isValid('User|Admin'))->toBeFalse() + ->and(ClassNameValidator::isValid('Countable&ArrayAccess'))->toBeFalse() + ; + }); + + test('rejects syntactically invalid class identifiers and non-strings', function () { + expect(ClassNameValidator::isValid('123InvalidClass'))->toBeFalse() + ->and(ClassNameValidator::isValid('Invalid-Class-Name'))->toBeFalse() + ->and(ClassNameValidator::isValid(''))->toBeFalse() + ->and(ClassNameValidator::isValid('\\'))->toBeFalse() + ->and(ClassNameValidator::isValid(123))->toBeFalse() + ->and(ClassNameValidator::isValid(null))->toBeFalse() + ->and(ClassNameValidator::isValid([]))->toBeFalse() + ->and(ClassNameValidator::isValid(new stdClass()))->toBeFalse() + ; + }); + + test('retrieves cached results on subsequent calls to isValid', function () { + expect(ClassNameValidator::isValid('App\Models\Order'))->toBeTrue(); + expect(ClassNameValidator::isValid('App\Models\Order'))->toBeTrue(); - test('rejects syntactically invalid class identifiers and non-strings', function () { - expect(ClassNameValidator::isValid('123InvalidClass'))->toBeFalse(); - expect(ClassNameValidator::isValid('Invalid-Class-Name'))->toBeFalse(); - expect(ClassNameValidator::isValid(''))->toBeFalse(); - expect(ClassNameValidator::isValid('\\'))->toBeFalse(); - expect(ClassNameValidator::isValid(123))->toBeFalse(); - expect(ClassNameValidator::isValid(null))->toBeFalse(); - expect(ClassNameValidator::isValid([]))->toBeFalse(); - expect(ClassNameValidator::isValid(new stdClass()))->toBeFalse(); + expect(ClassNameValidator::isValid('123Bad'))->toBeFalse(); + expect(ClassNameValidator::isValid('123Bad'))->toBeFalse(); + }); }); - test('accepts anonymous class names registered in memory', function () { - $anon = new class () {}; - expect(ClassNameValidator::isValid($anon::class))->toBeTrue(); + describe('isValidClassString() - Class-String Validator', function () { + test('accepts existing unqualified built-in PHP classes and interfaces', function () { + expect(ClassNameValidator::isValidClassString('stdClass'))->toBeTrue() + ->and(ClassNameValidator::isValidClassString('DateTime'))->toBeTrue() + ->and(ClassNameValidator::isValidClassString('Countable'))->toBeTrue() + ->and(ClassNameValidator::isValidClassString('ArrayAccess'))->toBeTrue() + ; + }); + + test('rejects non-existent unqualified class names', function () { + expect(ClassNameValidator::isValidClassString('NonExistentUnqualifiedClass'))->toBeFalse() + ->and(ClassNameValidator::isValidClassString('RandomUnknownName'))->toBeFalse() + ; + }); + + test('accepts existing fully qualified class-string names with autoloading', function () { + expect(ClassNameValidator::isValidClassString(stdClass::class))->toBeTrue() + ->and(ClassNameValidator::isValidClassString(User::class))->toBeTrue() + ->and(ClassNameValidator::isValidClassString('\TypePHP\Tests\Fixtures\Domain\Dog'))->toBeTrue() + ; + }); + + test('accepts syntactically valid qualified synthetic class-strings', function () { + expect(ClassNameValidator::isValidClassString('App\Models\User'))->toBeTrue() + ->and(ClassNameValidator::isValidClassString('\Vendor\Package\CustomModel'))->toBeTrue() + ; + }); + + test('rejects non-strings, empty strings, and invalid syntax in isValidClassString', function () { + expect(ClassNameValidator::isValidClassString(''))->toBeFalse() + ->and(ClassNameValidator::isValidClassString(null))->toBeFalse() + ->and(ClassNameValidator::isValidClassString(12345))->toBeFalse() + ->and(ClassNameValidator::isValidClassString([]))->toBeFalse() + ->and(ClassNameValidator::isValidClassString('Invalid-Syntax!'))->toBeFalse() + ->and(ClassNameValidator::isValidClassString('123InvalidStart'))->toBeFalse() + ; + }); + + test('retrieves cached results on subsequent calls to isValidClassString', function () { + expect(ClassNameValidator::isValidClassString(stdClass::class))->toBeTrue(); + expect(ClassNameValidator::isValidClassString(stdClass::class))->toBeTrue(); + + expect(ClassNameValidator::isValidClassString('UnknownUnqualified'))->toBeFalse(); + expect(ClassNameValidator::isValidClassString('UnknownUnqualified'))->toBeFalse(); + }); }); -}); +}); \ No newline at end of file diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php index 179d4cd..2ed6b66 100644 --- a/tests/Internal/Util/IgnoreManagerTest.php +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -35,33 +35,60 @@ public static function executeNormal(): bool } /** - * Fixture: Class-level ignore caller - * - * @typephp-ignore + * Fixture: Helper for testing in-loop cached method ignore hits */ -class IgnoredClassCallerFixture +class TraceLoopCacheCallerFixture { - public static function executeClassIgnored(): bool + /** + * @typephp-ignore + */ + public static function outerIgnored(): bool + { + return self::innerWorker(); + } + + public static function innerWorker(): bool { return IgnoreManager::isCallerIgnored(); } } /** - * Fixture: Standard, un-annotated caller + * Fixture: Helper for testing in-loop cached false continue progression */ -class NormalCallerFixture +class TraceLoopCacheChainFixture { - public static function execute(): bool + /** + * @typephp-ignore + */ + public static function ignoredGrandParent(): bool + { + return self::normalParent(); + } + + public static function normalParent(): bool + { + return IgnoreManager::isCallerIgnored(); + } +} + +/** + * Fixture: Class-level ignore caller + * + * @typephp-ignore + */ +class IgnoredClassCallerFixture +{ + public static function executeClassIgnored(): bool { return IgnoreManager::isCallerIgnored(); } } /** - * Fixture: Dedicated caller for stub-based ignore testing + * Fixture: Standard, un-annotated caller */ -class StubIgnoredCallerFixture +class NormalCallerFixture { public static function execute(): bool { @@ -87,6 +114,25 @@ function testNormalStandaloneCaller(): bool return IgnoreManager::isCallerIgnored(); } +/** + * Helper to simulate pest internal frames + */ +function __pest_test_runner_sim(): bool +{ + return IgnoreManager::isCallerIgnored(); +} + +/** + * Fixture: Helper class for testing file-level ignore in call stack + */ +class IgnoredFileCallerFixture +{ + public static function call(): bool + { + return IgnoreManager::isCallerIgnored(); + } +} + describe('IgnoreManager Unit Tests', function () { beforeEach(function () { Config::reset(); @@ -122,6 +168,15 @@ function testNormalStandaloneCaller(): bool expect(IgnoreManager::isFileIgnored(''))->toBeFalse(); }); + test('suppresses checks when caller file is registered as ignored in stack trace', function () { + $ref = new \ReflectionClass(IgnoredFileCallerFixture::class); + $file = (string) $ref->getFileName(); + + IgnoreManager::registerIgnoredFile($file); + + expect(IgnoredFileCallerFixture::call())->toBeTrue(); + }); + test('clears file registry on reset', function () { $path = '/var/www/app/Test.php'; IgnoreManager::registerIgnoredFile($path); @@ -151,42 +206,58 @@ function testNormalStandaloneCaller(): bool ; }); - test('identifies standalone functions marked with @typephp-ignore', function () { - expect(testIgnoredStandaloneCaller())->toBeTrue() - ->and(testNormalStandaloneCaller())->toBeFalse() - ; + test('identifies standalone functions marked with @typephp-ignore and hits function cache on second run', function () { + expect(testIgnoredStandaloneCaller())->toBeTrue(); + expect(testIgnoredStandaloneCaller())->toBeTrue(); + expect(testNormalStandaloneCaller())->toBeFalse(); }); - test('supports direct explicit caller overrides', function () { - expect(IgnoreManager::isCallerIgnored(IgnoredMethodCallerFixture::class, 'executeIgnored'))->toBeTrue() - ->and(IgnoreManager::isCallerIgnored(NormalCallerFixture::class, 'execute'))->toBeFalse() - ->and(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\testIgnoredStandaloneCaller'))->toBeTrue() - ; + test('skips simulated pest runner frames in stack trace', function () { + expect(__pest_test_runner_sim())->toBeFalse(); }); - }); - describe('Stub-Based Caller Ignore Detection', function () { - beforeEach(function () { - Config::reset(); - IgnoreManager::reset(); + test('supports direct explicit caller overrides and hits cache on subsequent checks', function () { + expect(IgnoreManager::isCallerIgnored(IgnoredMethodCallerFixture::class, 'executeIgnored'))->toBeTrue(); + expect(IgnoreManager::isCallerIgnored(IgnoredMethodCallerFixture::class, 'executeIgnored'))->toBeTrue(); + + expect(IgnoreManager::isCallerIgnored(NormalCallerFixture::class, 'execute'))->toBeFalse(); + expect(IgnoreManager::isCallerIgnored(NormalCallerFixture::class, 'execute'))->toBeFalse(); + + expect(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\testIgnoredStandaloneCaller'))->toBeTrue(); + expect(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\testIgnoredStandaloneCaller'))->toBeTrue(); + + expect(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\testNormalStandaloneCaller'))->toBeFalse(); + expect(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\testNormalStandaloneCaller'))->toBeFalse(); }); - afterEach(function () { - Config::reset(); - IgnoreManager::reset(); + test('returns false cleanly when checking non-existent classes and functions', function () { + expect(IgnoreManager::isCallerIgnored('NonExistentClass12345', 'anyMethod'))->toBeFalse(); + expect(IgnoreManager::isCallerIgnored(null, 'non_existent_function_12345'))->toBeFalse(); }); - test('identifies caller methods ignored via external stub files', function () { + test('hits in-loop cache for previously verified ignored caller method in stack trace', function () { + expect(TraceLoopCacheCallerFixture::outerIgnored())->toBeTrue(); + expect(TraceLoopCacheCallerFixture::outerIgnored())->toBeTrue(); + }); + + test('hits in-loop cache for un-ignored intermediate method and continues up stack to ancestor', function () { + expect(TraceLoopCacheChainFixture::normalParent())->toBeFalse(); + expect(TraceLoopCacheChainFixture::ignoredGrandParent())->toBeTrue(); + }); + }); + + describe('Stub-Based Caller Ignore Detection (Method, Class, and Function Stubs)', function () { + test('identifies caller methods, classes, and functions ignored via external stub files', function () { $tempDir = sys_get_temp_dir() . '/typephp_ignore_stub_' . uniqid(); mkdir($tempDir, 0777, true); - $stubPath = $tempDir . '/StubIgnoredCallerFixture.stub'; + $stubPath = $tempDir . '/Stubs.stub'; $stubContent = <<<'PHP' toBeTrue(); + expect(IgnoreManager::isCallerIgnored('TypePHP\Tests\Internal\Util\StubIgnoredMethodCaller', 'execute'))->toBeTrue(); + expect(IgnoreManager::isCallerIgnored('TypePHP\Tests\Internal\Util\StubIgnoredClassCaller', 'execute'))->toBeTrue(); + expect(IgnoreManager::isCallerIgnored(null, 'TypePHP\Tests\Internal\Util\stubIgnoredFunction'))->toBeTrue(); } finally { if (file_exists($stubPath)) { @unlink($stubPath); @@ -231,14 +321,4 @@ public static function execute(): bool } }); }); - - describe('In-Memory Decision Caching ($O(1) Memoization)', function () { - test('retrieves subsequent caller decisions directly from cache', function () { - expect(IgnoredMethodCallerFixture::executeIgnored())->toBeTrue(); - expect(NormalCallerFixture::execute())->toBeFalse(); - - expect(IgnoredMethodCallerFixture::executeIgnored())->toBeTrue(); - expect(NormalCallerFixture::execute())->toBeFalse(); - }); - }); }); From f9ef66935d73481ce54bbfad6172675e96258cc9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 22 Sep 2026 18:11:32 +0800 Subject: [PATCH 06/17] Enhance test coverage for PathMatcher by adding tests for static source path identification and path inclusion logic --- src/Internal/Util/PathMatcher.php | 4 -- tests/Internal/Util/PathMatcherTest.php | 64 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index 6fbd272..3284012 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -165,10 +165,6 @@ public static function isStaticSourcePath(string $normalizedPath): bool return true; } - if (preg_match('#(^|/)packages/[^/]+/src/#', $canon) === 1) { - return true; - } - return false; } diff --git a/tests/Internal/Util/PathMatcherTest.php b/tests/Internal/Util/PathMatcherTest.php index c35231f..14661ae 100644 --- a/tests/Internal/Util/PathMatcherTest.php +++ b/tests/Internal/Util/PathMatcherTest.php @@ -44,10 +44,16 @@ expect(PathMatcher::canonicalizePath('C:/project/../../App.php'))->toBe('C:/App.php'); }); + test('resolves relative traversals collapsing to root slash', function () { + expect(PathMatcher::canonicalizePath('/app/..'))->toBe('/') + ->and(PathMatcher::canonicalizePath('/./'))->toBe('/') + ->and(PathMatcher::canonicalizePath('/var/www/../..'))->toBe('/') + ; + }); + test('returns unchanged path when no dots or traversals exist', function () { expect(PathMatcher::canonicalizePath('src/Services/UserService.php')) - ->toBe('src/Services/UserService.php') - ; + ->toBe('src/Services/UserService.php'); }); test('collapses relative directory traversals (..) and current directory dots (.)', function () { @@ -222,6 +228,31 @@ }); }); + describe('isStaticSourcePath()', function () { + test('identifies static source paths accurately across vendor, src, app, lib, and packages', function () { + expect(PathMatcher::isStaticSourcePath('vendor/monolog/monolog/src/Logger.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('/var/www/src/Service.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('app/Services/UserService.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('/project/app/Models/User.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('lib/Services/PaymentProcessor.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('/var/www/lib/Helper.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('packages/auth/src/AuthService.php'))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath('/project/packages/core/src/Application.php'))->toBeTrue() + ; + }); + + test('rejects dynamic writable, tests, and temporary paths in isStaticSourcePath', function () { + expect(PathMatcher::isStaticSourcePath('/var/www/var/cache/prod/Container.php'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('storage/logs/laravel.log'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('tests/Unit/SampleTest.php'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('/var/www/tests/Fixtures/file.txt'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('/tmp/cache.php'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('/project/install/setup.php'))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath('/unknown/unmatched/file.php'))->toBeFalse() + ; + }); + }); + describe('mayPathBeIncluded() Fast-Path String Pre-Filter', function () { test('rejects node_modules and TypePHP cache unconditionally', function () { $cacheDir = PathMatcher::normalizePath(CacheManager::getCacheDir()); @@ -232,6 +263,35 @@ ; }); + test('handles macOS /private/var/ system paths accurately in mayPathBeIncluded', function () { + expect(PathMatcher::mayPathBeIncluded('/private/var/www/src/index.php'))->toBeTrue(); + + Config::set(['include' => ['src/**'], 'exclude' => ['vendor/**']]); + expect(PathMatcher::mayPathBeIncluded('/private/var/tmp/var/cache/test.php'))->toBeFalse(); + }); + + test('rejects tests directory when tests prefix is not in include patterns', function () { + Config::set([ + 'include' => ['src/**', 'app/**'], + 'exclude' => ['vendor/**'], + ]); + + expect(PathMatcher::mayPathBeIncluded('tests/Unit/SampleTest.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/project/tests/Feature/UserTest.php'))->toBeFalse() + ; + }); + + test('rejects migration files when Migration prefix is in exclude patterns', function () { + Config::set([ + 'include' => ['src/**'], + 'exclude' => ['src/Migration/**', 'vendor/**'], + ]); + + expect(PathMatcher::mayPathBeIncluded('src/Migration/Migration123.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/src/migration/v1.php'))->toBeFalse() + ; + }); + test('rejects unwhitelisted vendor, var, and storage paths when config does not include them', function () { try { Config::set([ From ee973af4e57f6154998a79b55f39a183da57e947 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 23 Sep 2026 14:41:06 +0800 Subject: [PATCH 07/17] Enhance test coverage for Config by adding tests for project root resolution and configuration handling --- src/Internal/Util/Config.php | 8 +- tests/Internal/Util/ConfigTest.php | 196 +++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 26 deletions(-) diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 1e60b10..4119826 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -284,14 +284,14 @@ public static function getArrayValidationStrategy(): string * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. * Caches the result in memory so the search happens exactly once. */ - public static function getProjectRoot(): string + public static function getProjectRoot(?string $startingDir = null): string { - if (self::$projectRoot !== null) { + if ($startingDir === null && self::$projectRoot !== null) { return self::$projectRoot; } $cwd = getcwd(); - if ($cwd !== false) { + if ($startingDir === null && $cwd !== false) { $realCwd = realpath($cwd) !== false ? realpath($cwd) : $cwd; $normCwd = rtrim(str_replace('\\', '/', (string) $realCwd), '/'); if ( @@ -302,7 +302,7 @@ public static function getProjectRoot(): string } } - $dir = str_replace('\\', '/', __DIR__); + $dir = str_replace('\\', '/', $startingDir ?? __DIR__); if (str_contains($dir, '/vendor/')) { $vendorPos = strrpos($dir, '/vendor/'); diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php index fd9cc84..f07e5fd 100644 --- a/tests/Internal/Util/ConfigTest.php +++ b/tests/Internal/Util/ConfigTest.php @@ -40,6 +40,97 @@ expect(Config::get())->toBeArray(); }); + test('initializes cachedConfig on demand across all getters when uninitialized and tests cached branch', function () { + $getters = [ + 'isEnabled', + 'getIgnoreTraceDepth', + 'isInlinePropertiesEnabled', + 'isInlineGenericsEnabled', + 'isInlineCallablesEnabled', + 'isInlineScalarsEnabled', + 'isInlineArraysEnabled', + 'isInlineObjectsEnabled', + 'hasActiveInlineChecks', + 'isCacheCheckMtimeEnabled', + 'isParamsOutEnabled', + 'isSelfOutEnabled', + 'isParamsEnabled', + 'isReturnsEnabled', + 'isStrictReturnGenericInvarianceEnabled', + 'isMagicPropertiesEnabled', + 'isMagicMethodsEnabled', + 'isRespectIgnoreTagsEnabled', + 'isRespectNativeNullabilityEnabled', + 'isVendorBoundaryOnlyEnabled', + 'isArrayValidationHybrid', + 'getArrayValidationStrategy', + ]; + + foreach ($getters as $getter) { + Config::reset(); + // First call triggers: if (self::$cachedConfig === null) { self::get(); } + $val1 = Config::$getter(); + + // Second call triggers the false branch (already cached) + $val2 = Config::$getter(); + + expect($val1)->toBe($val2); + } + }); + + test('hasActiveInlineChecks returns false when all inline var checks are disabled', function () { + Config::set([ + 'inline_vars' => [ + 'generics' => false, + 'callables' => false, + 'scalars' => false, + 'arrays' => false, + 'objects' => false, + ], + ]); + + expect(Config::hasActiveInlineChecks())->toBeFalse(); + + Config::set([ + 'inline_vars' => [ + 'objects' => true, + ], + ]); + expect(Config::hasActiveInlineChecks())->toBeTrue(); + }); + + test('handles array validation strategy configuration', function () { + Config::set(['array_validation' => 'hybrid']); + expect(Config::isArrayValidationHybrid())->toBeTrue() + ->and(Config::getArrayValidationStrategy())->toBe('hybrid') + ; + + Config::set(['array_validation' => 'full']); + expect(Config::isArrayValidationHybrid())->toBeFalse() + ->and(Config::getArrayValidationStrategy())->toBe('full') + ; + + // Non-string fallback + Config::set(['array_validation' => 12345]); + expect(Config::getArrayValidationStrategy())->toBe('full'); + }); + + test('syncFlags correctly validates and falls back on edge-case inputs', function () { + // Invalid ignore_trace_depth values fallback to 25 + Config::set(['ignore_trace_depth' => -5]); + expect(Config::getIgnoreTraceDepth())->toBe(25); + + Config::set(['ignore_trace_depth' => 'invalid_string']); + expect(Config::getIgnoreTraceDepth())->toBe(25); + + Config::set(['ignore_trace_depth' => 40]); + expect(Config::getIgnoreTraceDepth())->toBe(40); + + // Non-array inline_vars fallback + Config::set(['inline_vars' => null]); + expect(Config::isInlinePropertiesEnabled())->toBeTrue(); + }); + test('resolves and memoizes project root path via getProjectRoot', function () { $root1 = Config::getProjectRoot(); $root2 = Config::getProjectRoot(); @@ -98,43 +189,69 @@ ; }); - test('resolves consumer project root when TypePHP is installed inside vendor/typephp/typephp', function () { - $tempBase = sys_get_temp_dir() . '/typephp_root_test_' . uniqid(); - $vendorDir = $tempBase . '/vendor/typephp/typephp/src/Internal'; - mkdir($vendorDir, 0777, true); + test('resolves project root when installed in vendor using startingDir parameter', function () { + $tempBase = sys_get_temp_dir() . '/typephp_vendor_test_' . uniqid(); + $fakeVendorDir = $tempBase . '/vendor/typephp/typephp/src/Internal/Util'; + mkdir($fakeVendorDir, 0777, true); file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/consumer-app'])); - file_put_contents($tempBase . '/vendor/autoload.php', ' 'typephp/typephp'])); try { - $prevCwd = getcwd(); - chdir($tempBase); - - Config::reset(); - - $root = Config::getProjectRoot(); + $resolved = Config::getProjectRoot($fakeVendorDir); $realTempBase = realpath($tempBase) !== false ? realpath($tempBase) : $tempBase; $normTempBase = rtrim(str_replace('\\', '/', (string) $realTempBase), '/'); - expect($root)->toBe($normTempBase) - ->and($root)->not()->toContain('vendor/typephp/typephp') - ; - - if ($prevCwd !== false) { - chdir($prevCwd); - } + expect($resolved)->toBe($normTempBase); } finally { @unlink($tempBase . '/composer.json'); - @unlink($tempBase . '/vendor/autoload.php'); - @unlink($tempBase . '/vendor/typephp/typephp/composer.json'); + @rmdir($fakeVendorDir); @rmdir($tempBase . '/vendor/typephp/typephp/src/Internal'); @rmdir($tempBase . '/vendor/typephp/typephp/src'); @rmdir($tempBase . '/vendor/typephp/typephp'); @rmdir($tempBase . '/vendor/typephp'); @rmdir($tempBase . '/vendor'); @rmdir($tempBase); - Config::reset(); + } + }); + + test('resolves project root by climbing parent directories when not in vendor', function () { + $tempBase = sys_get_temp_dir() . '/typephp_climb_test_' . uniqid(); + $nestedSubDir = $tempBase . '/src/Modules/Commerce/Services'; + mkdir($nestedSubDir, 0777, true); + + file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/monorepo'])); + + try { + $resolved = Config::getProjectRoot($nestedSubDir); + $realTempBase = realpath($tempBase) !== false ? realpath($tempBase) : $tempBase; + $normTempBase = rtrim(str_replace('\\', '/', (string) $realTempBase), '/'); + + expect($resolved)->toBe($normTempBase); + } finally { + @unlink($tempBase . '/composer.json'); + @rmdir($nestedSubDir); + @rmdir($tempBase . '/src/Modules/Commerce'); + @rmdir($tempBase . '/src/Modules'); + @rmdir($tempBase . '/src'); + @rmdir($tempBase); + } + }); + + test('falls back to current directory when no composer.json or autoload.php is found after 10 parent steps', function () { + $tempBase = sys_get_temp_dir() . '/typephp_deep_empty_' . uniqid(); + $deepDir = $tempBase . '/1/2/3/4/5/6/7/8/9/10/11'; + mkdir($deepDir, 0777, true); + + try { + $resolved = Config::getProjectRoot($deepDir); + expect($resolved)->toBeString() + ->and($resolved)->not()->toBeEmpty() + ; + } finally { + for ($d = $deepDir; $d !== $tempBase; $d = dirname($d)) { + @rmdir($d); + } + @rmdir($tempBase); } }); @@ -148,4 +265,39 @@ Config::set(['params' => false, 'params_out' => true]); expect(Config::isParamsOutEnabled())->toBeFalse(); }); + + test('set initializes cachedConfig if called when cachedConfig is null', function () { + Config::reset(); + + Config::set(['enabled' => false]); + + expect(Config::isEnabled())->toBeFalse(); + }); + + test('falls back to defaultConfig extensions when typephp.php does not exist', function () { + $tempDir = sys_get_temp_dir() . '/typephp_no_config_' . uniqid(); + mkdir($tempDir, 0777, true); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + $config = Config::get(); + expect($config['extensions'])->toBeEmpty(); + } finally { + Config::reset(); + @rmdir($tempDir); + } + }); + + test('hits root break when directory traversal reaches filesystem root', function () { + $root = DIRECTORY_SEPARATOR === '/' ? '/' : 'C:/'; + $result = Config::getProjectRoot($root); + + expect($result)->toBeString() + ->and($result)->not()->toBeEmpty(); + }); }); From 009910f841d79c0e764b497743f23ea07989c0f9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 23 Sep 2026 15:42:20 +0800 Subject: [PATCH 08/17] Refactor StubManager and enhance test coverage with new tests for resolveStubFiles and loadStubFiles methods --- src/Internal/Util/IgnoreManager.php | 2 +- src/Internal/Util/StubManager.php | 58 ++++---- .../Internal/Util/ClassNameValidatorTest.php | 5 +- tests/Internal/Util/ConfigTest.php | 5 +- tests/Internal/Util/PathMatcherTest.php | 3 +- tests/Internal/Util/StubManagerTest.php | 129 +++++++++++++++++- 6 files changed, 156 insertions(+), 46 deletions(-) diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php index 9a9293c..b598de0 100644 --- a/src/Internal/Util/IgnoreManager.php +++ b/src/Internal/Util/IgnoreManager.php @@ -227,4 +227,4 @@ private static function checkFunctionIgnored(string $function): bool return self::hasIgnoreDocTag($refFunc->getDocComment()); } -} \ No newline at end of file +} diff --git a/src/Internal/Util/StubManager.php b/src/Internal/Util/StubManager.php index 25a497e..4d257df 100644 --- a/src/Internal/Util/StubManager.php +++ b/src/Internal/Util/StubManager.php @@ -143,17 +143,15 @@ private static function loadStubFiles(array $globs): void foreach ($files as $file) { $source = file_get_contents($file); - if ($source === false) { - continue; - } - - try { - $stmts = $parser->parse($source); - if ($stmts !== null) { - self::extractStubsFromAst($stmts); + if ($source !== false) { + try { + $stmts = $parser->parse($source); + if ($stmts !== null) { + self::extractStubsFromAst($stmts); + } + } catch (Throwable $e) { + // Silently ignore malformed stub files } - } catch (Throwable $e) { - // Silently ignore malformed stub files } } } @@ -186,21 +184,17 @@ private static function resolveStubFiles(string $pattern, string $projectRoot): $regex = PathMatcher::compileGlobToRegex($pattern, $projectRoot); $matchedFiles = []; - try { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS) - ); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS) + ); - foreach ($iterator as $fileInfo) { - if ($fileInfo instanceof SplFileInfo && $fileInfo->isFile()) { - $real = str_replace('\\', '/', (string) $fileInfo->getRealPath()); - if (preg_match($regex, $real) === 1 || preg_match($regex, str_replace('\\', '/', $fileInfo->getPathname())) === 1) { - $matchedFiles[] = $real; - } + foreach ($iterator as $fileInfo) { + if ($fileInfo instanceof SplFileInfo && $fileInfo->isFile()) { + $real = str_replace('\\', '/', (string) $fileInfo->getRealPath()); + if (preg_match($regex, $real) === 1 || preg_match($regex, str_replace('\\', '/', $fileInfo->getPathname())) === 1) { + $matchedFiles[] = $real; } } - } catch (Throwable $e) { - // Ignore filesystem access errors } return $matchedFiles; @@ -209,20 +203,16 @@ private static function resolveStubFiles(string $pattern, string $projectRoot): if (is_dir($fullPath)) { $matchedFiles = []; - try { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($fullPath, FilesystemIterator::SKIP_DOTS) - ); - foreach ($iterator as $fileInfo) { - if ($fileInfo instanceof SplFileInfo && $fileInfo->isFile()) { - $real = $fileInfo->getRealPath(); - if ($real !== false) { - $matchedFiles[] = str_replace('\\', '/', $real); - } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($fullPath, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $fileInfo) { + if ($fileInfo instanceof SplFileInfo && $fileInfo->isFile()) { + $real = $fileInfo->getRealPath(); + if ($real !== false) { + $matchedFiles[] = str_replace('\\', '/', $real); } } - } catch (Throwable $e) { - // Ignore filesystem access errors } return $matchedFiles; diff --git a/tests/Internal/Util/ClassNameValidatorTest.php b/tests/Internal/Util/ClassNameValidatorTest.php index 6b5e30c..0b4d393 100644 --- a/tests/Internal/Util/ClassNameValidatorTest.php +++ b/tests/Internal/Util/ClassNameValidatorTest.php @@ -4,9 +4,6 @@ namespace TypePHP\Tests\Internal\Util; -use ArrayAccess; -use Countable; -use DateTime; use stdClass; use TypePHP\Internal\Util\ClassNameValidator; use TypePHP\Tests\Fixtures\Domain\User; @@ -106,4 +103,4 @@ expect(ClassNameValidator::isValidClassString('UnknownUnqualified'))->toBeFalse(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php index f07e5fd..32d9808 100644 --- a/tests/Internal/Util/ConfigTest.php +++ b/tests/Internal/Util/ConfigTest.php @@ -248,7 +248,7 @@ ->and($resolved)->not()->toBeEmpty() ; } finally { - for ($d = $deepDir; $d !== $tempBase; $d = dirname($d)) { + for ($d = $deepDir; $d !== $tempBase; $d = \dirname($d)) { @rmdir($d); } @rmdir($tempBase); @@ -298,6 +298,7 @@ $result = Config::getProjectRoot($root); expect($result)->toBeString() - ->and($result)->not()->toBeEmpty(); + ->and($result)->not()->toBeEmpty() + ; }); }); diff --git a/tests/Internal/Util/PathMatcherTest.php b/tests/Internal/Util/PathMatcherTest.php index 14661ae..a5f32f9 100644 --- a/tests/Internal/Util/PathMatcherTest.php +++ b/tests/Internal/Util/PathMatcherTest.php @@ -53,7 +53,8 @@ test('returns unchanged path when no dots or traversals exist', function () { expect(PathMatcher::canonicalizePath('src/Services/UserService.php')) - ->toBe('src/Services/UserService.php'); + ->toBe('src/Services/UserService.php') + ; }); test('collapses relative directory traversals (..) and current directory dots (.)', function () { diff --git a/tests/Internal/Util/StubManagerTest.php b/tests/Internal/Util/StubManagerTest.php index c4d82d5..33eb5a3 100644 --- a/tests/Internal/Util/StubManagerTest.php +++ b/tests/Internal/Util/StubManagerTest.php @@ -2,15 +2,14 @@ declare(strict_types=1); +use PhpParser\Node\Stmt\Class_; +use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\StubManager; describe('StubManager Unit Tests', function () { - beforeEach(function () { - Config::reset(); - }); - afterEach(function () { + StubManager::reset(); Config::reset(); }); @@ -102,6 +101,128 @@ function helperFunction(int $code): void if (is_dir($tempDir)) { @rmdir($tempDir); } + + StubManager::reset(); + Config::reset(); + } + }); + + test('resolveStubFiles covers direct file, directory path, wildcard baseDir fallback, and non-existent path', function () { + $ref = new ReflectionClass(StubManager::class); + $method = $ref->getMethod('resolveStubFiles'); + + $tempDir = sys_get_temp_dir() . '/typephp_resolve_stubs_' . uniqid(); + mkdir($tempDir, 0777, true); + $dummyFile = $tempDir . '/dummy.stub'; + file_put_contents($dummyFile, 'invoke(null, $dummyFile, $tempDir); + expect($resFile)->toBe([str_replace('\\', '/', $dummyFile)]); + + // 2. Directory path without wildcard: is_dir($fullPath) + $resDir = $method->invoke(null, $tempDir, $tempDir); + expect($resDir)->toContain(str_replace('\\', '/', $dummyFile)); + + // 3. Wildcard with missing baseDir: !is_dir($baseDir) => return [] + $resMissingBase = $method->invoke(null, $tempDir . '/missing_dir_123/sub/*.stub', $tempDir); + expect($resMissingBase)->toBe([]); + + // 4. Non-existent path without wildcard => falls through to return [] + $resMissingFile = $method->invoke(null, $tempDir . '/non_existent_file.stub', $tempDir); + expect($resMissingFile)->toBe([]); + } finally { + @unlink($dummyFile); + @rmdir($tempDir); } }); + + test('loadStubFiles catches parse errors on malformed stub files', function () { + $ref = new ReflectionClass(StubManager::class); + $method = $ref->getMethod('loadStubFiles'); + + $tempDir = sys_get_temp_dir() . '/typephp_load_stubs_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Invalid PHP syntax triggers catch (Throwable $e) + $badSyntaxFile = $tempDir . '/syntax_error.stub'; + file_put_contents($badSyntaxFile, 'invoke(null, [$badSyntaxFile]); + + expect(StubManager::hasClassStub('SyntaxError'))->toBeFalse(); + } finally { + @unlink($badSyntaxFile); + @rmdir($tempDir); + } + }); + + test('extractStubsFromAst extracts interfaces, traits, enums and skips anonymous classes', function () { + $ref = new ReflectionClass(StubManager::class); + $method = $ref->getMethod('extractStubsFromAst'); + + $code = <<<'PHP' +parse($code); + expect($stmts)->not()->toBeNull(); + + // 1. Process interface, trait, and enum statements + $method->invoke(null, $stmts); + + expect(StubManager::hasClassStub('Vendor\AstTest\AstStubInterface'))->toBeTrue() + ->and(StubManager::hasMethodStub('Vendor\AstTest\AstStubInterface', 'getId'))->toBeTrue() + ->and(StubManager::hasClassStub('Vendor\AstTest\AstStubTrait'))->toBeTrue() + ->and(StubManager::getPropertyDoc('Vendor\AstTest\AstStubTrait', 'counter'))->toContain('@var positive-int') + ->and(StubManager::getMethodDoc('Vendor\AstTest\AstStubTrait', 'getName'))->toContain('@return non-empty-string') + ->and(StubManager::hasClassStub('Vendor\AstTest\AstStubEnum'))->toBeTrue() + ->and(StubManager::getClassDoc('Vendor\AstTest\AstStubEnum'))->toContain('Enum doc') + ; + + // 2. Anonymous class node triggers `if ($stmt->name === null) continue;` + $method->invoke(null, [new Class_(null)]); + }); }); From ef45579344930036c10b89e46d34c7d50af25033 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 23 Sep 2026 18:30:14 +0800 Subject: [PATCH 09/17] Improve test coverage --- phpunit.xml | 8 +- src/Internal/Diagnostic/ErrorFactory.php | 33 +- src/Internal/Wrapper/IteratorProxy.php | 7 +- .../Internal/Diagnostic/ErrorFactoryTest.php | 50 +- .../Internal/Wrapper/CallableWrapperTest.php | 792 ++++++++++++++---- .../Internal/Wrapper/IterableWrapperTest.php | 323 +++++++ tests/Internal/Wrapper/IteratorProxyTest.php | 24 +- 7 files changed, 1063 insertions(+), 174 deletions(-) create mode 100644 tests/Internal/Wrapper/IterableWrapperTest.php diff --git a/phpunit.xml b/phpunit.xml index c3ba311..484c56f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,8 @@ - + ./tests @@ -9,5 +12,8 @@ src + + src/bootstrap.php + \ No newline at end of file diff --git a/src/Internal/Diagnostic/ErrorFactory.php b/src/Internal/Diagnostic/ErrorFactory.php index 0d9b8a5..bf0148b 100644 --- a/src/Internal/Diagnostic/ErrorFactory.php +++ b/src/Internal/Diagnostic/ErrorFactory.php @@ -5,7 +5,6 @@ namespace TypePHP\Internal\Diagnostic; use ReflectionClass; -use Throwable; use TypeError; /** @@ -168,28 +167,24 @@ private static function mutateException( ?int $targetLine, array $filteredTrace ): void { - try { - $ref = new ReflectionClass(\Error::class); + $ref = new ReflectionClass(\Error::class); - $propMessage = $ref->getProperty('message'); - $propMessage->setValue($e, $message); + $propMessage = $ref->getProperty('message'); + $propMessage->setValue($e, $message); - if ($targetFile !== null) { - $propFile = $ref->getProperty('file'); - $propFile->setValue($e, $targetFile); - } + if ($targetFile !== null) { + $propFile = $ref->getProperty('file'); + $propFile->setValue($e, $targetFile); + } - if ($targetLine !== null) { - $propLine = $ref->getProperty('line'); - $propLine->setValue($e, $targetLine); - } + if ($targetLine !== null) { + $propLine = $ref->getProperty('line'); + $propLine->setValue($e, $targetLine); + } - if (\count($filteredTrace) > 0) { - $propTrace = $ref->getProperty('trace'); - $propTrace->setValue($e, $filteredTrace); - } - } catch (Throwable $err) { - // Silently fallback if reflection mutation fails + if (\count($filteredTrace) > 0) { + $propTrace = $ref->getProperty('trace'); + $propTrace->setValue($e, $filteredTrace); } } } diff --git a/src/Internal/Wrapper/IteratorProxy.php b/src/Internal/Wrapper/IteratorProxy.php index 381193d..c613d49 100644 --- a/src/Internal/Wrapper/IteratorProxy.php +++ b/src/Internal/Wrapper/IteratorProxy.php @@ -41,11 +41,8 @@ private static function resolveIterator(Traversable $iterable): Iterator $iterable = $iterable->getIterator(); } - if ($iterable instanceof Iterator) { - return $iterable; - } - - return new \ArrayIterator(iterator_to_array($iterable)); + /** @var Iterator $iterable */ + return $iterable; } public function rewind(): void diff --git a/tests/Internal/Diagnostic/ErrorFactoryTest.php b/tests/Internal/Diagnostic/ErrorFactoryTest.php index bfe9b36..68c4368 100644 --- a/tests/Internal/Diagnostic/ErrorFactoryTest.php +++ b/tests/Internal/Diagnostic/ErrorFactoryTest.php @@ -8,8 +8,9 @@ test('error factory creates an ErrorMessage value object', function () { $err = ErrorFactory::createError('Test argument error message'); - expect($err)->toBeInstanceOf(ErrorMessage::class); - expect($err->getMessage())->toBe('Test argument error message'); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('Test argument error message') + ; }); test('prepareException converts standard TypeError into ExactTypeError with caller trace details', function () { @@ -19,9 +20,52 @@ expect($prepared)->toBeInstanceOf(TypeError::class); }); +test('prepareException mutates file and line directly when provided', function () { + $err = new TypeError('Test parameter failure'); + $prepared = ErrorFactory::prepareException($err, 100, '/app/Services/UserService.php'); + + expect($prepared->getFile())->toBe('/app/Services/UserService.php') + ->and($prepared->getLine())->toBe(100) + ; +}); + +test('sanitizeMessage strips CallableWrapper and replaces with target file and line', function () { + $rawMessage = 'Test error, called in /project/src/Internal/Wrapper/CallableWrapper.php on line 123'; + $err = new TypeError($rawMessage); + + $prepared = ErrorFactory::prepareException($err, 42, '/app/Action.php'); + + expect($prepared->getMessage())->toBe('Test error, called in /app/Action.php on line 42'); +}); + +test('sanitizeMessage strips CallableWrapper when target file and line are omitted', function () { + $rawMessage = 'Test error, called in /project/src/Internal/Wrapper/CallableWrapper.php on line 123'; + $err = new TypeError($rawMessage); + + $prepared = ErrorFactory::prepareException($err); + + expect($prepared->getMessage())->toBe('Test error'); +}); + +test('sanitizeMessage strips RunCommand CLI prefix', function () { + $rawMessage = 'TypePHP\Internal\Cli\RunCommand::Target error message'; + $err = new TypeError($rawMessage); + + $prepared = ErrorFactory::prepareException($err); + + expect($prepared->getMessage())->toBe('Target error message'); +}); + +test('createError converts null given to none returned for return contracts', function () { + $rawMsg = 'findUser(): Return value must be of type string, null given'; + $err = ErrorFactory::createError($rawMsg); + + expect($err->getMessage())->toBe('findUser(): Return value must be of type string, none returned'); +}); + test('does not corrupt user string literals containing the word given in return error messages', function () { $rawMsg = "getOrderDiscount(): Return value must be of type positive-int, string 'discount given to customer' given"; $err = ErrorFactory::createError($rawMsg); expect($err->getMessage())->toBe("getOrderDiscount(): Return value must be of type positive-int, string 'discount given to customer' returned"); -}); +}); \ No newline at end of file diff --git a/tests/Internal/Wrapper/CallableWrapperTest.php b/tests/Internal/Wrapper/CallableWrapperTest.php index e033d65..8bf3b06 100644 --- a/tests/Internal/Wrapper/CallableWrapperTest.php +++ b/tests/Internal/Wrapper/CallableWrapperTest.php @@ -2,176 +2,678 @@ declare(strict_types=1); +namespace TypePHP\Tests\Internal\Wrapper; + +use Closure; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; -use TypePHP\Exception\TypeError; +use ReflectionClass; +use TypeError; +use TypePHP\Internal\Docblock\DocblockParser; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; +use TypePHP\Internal\Util\Config; use TypePHP\Internal\Validator\TypeValidatorRegistry; use TypePHP\Internal\Wrapper\CallableWrapper; -describe('CallableWrapper Unit Tests', function () { - test('returns raw value if callable is not valid or node is not CallableTypeNode', function () { - $registry = new TypeValidatorRegistry(); - $notCallable = 'not_a_callable_string_xyz'; +/** + * @phpstan-type FormatterAlias callable(positive-int): non-empty-string + * + * @param FormatterAlias $formatter + */ +function fixtureAliasCallableFunction(callable $formatter): void +{ +} + +/** + * @param (callable(positive-int): non-empty-string)[] $formatters + */ +function fixtureArrayCallableFunction(array $formatters): void +{ +} + +/** + * @return callable(positive-int): non-empty-string + */ +function fixtureReturnCallableFunction(): callable +{ + return fn (int $x): string => "id_{$x}"; +} - $result = CallableWrapper::wrapTypeNode(null, $notCallable, 'prefix', $registry); +/** + * @param list $listCallbacks + * @param array $mapCallbacks + */ +function fixtureGenericCollectionCallables(array $listCallbacks, array $mapCallbacks): void +{ +} - expect($result)->toBe($notCallable); +describe('CallableWrapper Unit Tests', function () { + afterEach(function () { + CallerBoundaryResolver::reset(); + Config::reset(); }); - test('wraps callable and validates argument types on invocation', function () { - $registry = new TypeValidatorRegistry(); - $callable = fn (int $id): string => "id_{$id}"; + describe('wrap() Pipeline & Collection Wrapping', function () { + test('returns raw value if callable is not valid or node is not CallableTypeNode', function () { + $registry = new TypeValidatorRegistry(); + $notCallable = 'not_a_callable_string_xyz'; - $typeNode = new CallableTypeNode( - new IdentifierTypeNode('callable'), - [ - new CallableTypeParameterNode( - new IdentifierTypeNode('positive-int'), - false, - false, - 'id', - false - ), - ], - new IdentifierTypeNode('string'), - [] - ); - - $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestCallback', $registry); - - expect($wrapped(10))->toBe('id_10'); - - expect(fn () => $wrapped(-5)) - ->toThrow(TypeError::class, 'TestCallback $id must be of type positive-int') - ; + $result = CallableWrapper::wrapTypeNode(null, $notCallable, 'prefix', $registry); + + expect($result)->toBe($notCallable); + }); + + test('resolves type alias pointing to a callable', function () { + $registry = new TypeValidatorRegistry(); + $fn = fn (int $id): string => "user_{$id}"; + + $callableNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), false, false, 'id', false)], + new IdentifierTypeNode('string'), + [] + ); + + $ref = new ReflectionClass(DocblockParser::class); + $prop = $ref->getProperty('cache'); + $cache = $prop->getValue(); + $cache['testUnexpandedAliasFunc'] = [ + 'types' => ['formatter' => new IdentifierTypeNode('UnexpandedCallableAlias')], + 'return' => null, + 'aliases' => ['UnexpandedCallableAlias' => $callableNode], + 'templates' => [], + 'classTemplates' => [], + ]; + $prop->setValue(null, $cache); + + try { + $wrapped = CallableWrapper::wrap('testUnexpandedAliasFunc', 'formatter', $fn, $registry); + + expect($wrapped(10))->toBe('user_10'); + expect(fn () => $wrapped(-5))->toThrow(TypeError::class, 'positive-int'); + } finally { + DocblockParser::reset(); + } + }); + + test('wraps callable arrays with ArrayTypeNode and preserves non-callable items', function () { + $registry = new TypeValidatorRegistry(); + $fn = fn (int $id): string => "item_{$id}"; + + $items = [ + 'first' => $fn, + 'second' => 'not_a_callable_string', + ]; + + $wrapped = CallableWrapper::wrap( + __NAMESPACE__ . '\fixtureArrayCallableFunction', + 'formatters', + $items, + $registry + ); + + expect($wrapped['first'](20))->toBe('item_20') + ->and($wrapped['second'])->toBe('not_a_callable_string') + ; + + expect(fn () => $wrapped['first'](-1))->toThrow(TypeError::class, 'positive-int'); + }); + + test('wraps generic lists and maps of callables', function () { + $registry = new TypeValidatorRegistry(); + $fn = fn (int $x): string => "num_{$x}"; + + $wrappedList = CallableWrapper::wrap( + __NAMESPACE__ . '\fixtureGenericCollectionCallables', + 'listCallbacks', + [$fn], + $registry + ); + expect($wrappedList[0](5))->toBe('num_5'); + + $wrappedMap = CallableWrapper::wrap( + __NAMESPACE__ . '\fixtureGenericCollectionCallables', + 'mapCallbacks', + ['action' => $fn], + $registry + ); + expect($wrappedMap['action'](10))->toBe('num_10'); + }); + + test('returns raw non-callable value when passed to wrap()', function () { + $registry = new TypeValidatorRegistry(); + $res = CallableWrapper::wrap( + __NAMESPACE__ . '\fixtureAliasCallableFunction', + 'formatter', + 12345, + $registry + ); + + expect($res)->toBe(12345); + }); + + test('wraps return callable contracts', function () { + $registry = new TypeValidatorRegistry(); + $fn = fn (int $id): string => "ret_{$id}"; + + $wrapped = CallableWrapper::wrap( + __NAMESPACE__ . '\fixtureReturnCallableFunction', + 'return', + $fn, + $registry + ); + + expect($wrapped(42))->toBe('ret_42'); + }); }); - test('enforces static-closure constraints', function () { - $registry = new TypeValidatorRegistry(); - $typeNode = new CallableTypeNode( - new IdentifierTypeNode('static-closure'), - [], - new IdentifierTypeNode('void'), - [] - ); + describe('createDispatcherClosure() By-Reference Signatures & Error Trapping', function () { + test('wraps callable and validates argument types on invocation', function () { + $registry = new TypeValidatorRegistry(); + $callable = fn (int $id): string => "id_{$id}"; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('positive-int'), + false, + false, + 'id', + false + ), + ], + new IdentifierTypeNode('string'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestCallback', $registry); - $nonStatic = fn () => null; + expect($wrapped(10))->toBe('id_10'); - expect(fn () => CallableWrapper::wrapTypeNode($typeNode, $nonStatic, 'TestStatic', $registry)) - ->toThrow(TypeError::class, 'must be a static Closure') - ; + expect(fn () => $wrapped(-5)) + ->toThrow(TypeError::class, 'TestCallback $id must be of type positive-int') + ; + }); + + test('catches native TypeError in [true] single by-ref parameter dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $throwing = function (int &$num): void { + throw new TypeError('Native type error in single ref'); + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'num', false)], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestRefSingle', $registry); + $val = 10; + + expect(fn () => $wrapped($val))->toThrow(TypeError::class, 'Native type error in single ref'); + }); + + test('catches native TypeError in [true, false] mixed parameter dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $throwing = function (int &$a, string $b): void { + throw new TypeError('Native error in [true, false]'); + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'a', false), + new CallableTypeParameterNode(new IdentifierTypeNode('string'), false, false, 'b', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestTrueFalse', $registry); + $val = 10; + + expect(fn () => $wrapped($val, 'test'))->toThrow(TypeError::class, 'Native error in [true, false]'); + }); + + test('catches native TypeError in [false, true] mixed parameter dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $throwing = function (string $a, int &$b): void { + throw new TypeError('Native error in [false, true]'); + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('string'), false, false, 'a', false), + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'b', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestFalseTrue', $registry); + $val = 10; + + expect(fn () => $wrapped('test', $val))->toThrow(TypeError::class, 'Native error in [false, true]'); + }); + + test('executes and catches native TypeError in [true, true] dual by-ref dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $mutator = function (int &$a, int &$b): void { + $a += 10; + $b += 20; + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'a', false), + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'b', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $mutator, 'TestDualRef', $registry); + $x = 5; + $y = 15; + $wrapped($x, $y); + + expect($x)->toBe(15)->and($y)->toBe(35); + + $throwing = function (int &$a, int &$b): void { + throw new TypeError('Native error in dual ref'); + }; + $wrappedThrow = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestDualRef', $registry); + expect(fn () => $wrappedThrow($x, $y))->toThrow(TypeError::class, 'Native error in dual ref'); + }); + + test('catches native TypeError in variadic by-ref dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $throwing = function (int &...$numbers): void { + throw new TypeError('Native error in variadic ref'); + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, true, 'numbers', false)], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestVarRef', $registry); + $a = 10; + + expect(fn () => $wrapped($a))->toThrow(TypeError::class, 'Native error in variadic ref'); + }); + + test('executes and catches native TypeError in fallback 3+ parameter dispatcher', function () { + $registry = new TypeValidatorRegistry(); + $threeParams = function (int &$a, string $b, int &$c): void { + $a += 1; + $c += 2; + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'a', false), + new CallableTypeParameterNode(new IdentifierTypeNode('string'), false, false, 'b', false), + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'c', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $threeParams, 'TestFallbackRef', $registry); + $x = 10; + $msg = 'msg'; + $z = 30; + $wrapped($x, $msg, $z); + + expect($x)->toBe(11)->and($z)->toBe(32); + + $throwing = function (int &$a, string $b, int &$c): void { + throw new TypeError('Native error in fallback ref'); + }; + $wrappedThrow = CallableWrapper::wrapTypeNode($typeNode, $throwing, 'TestFallbackRef', $registry); + expect(fn () => $wrappedThrow($x, $msg, $z))->toThrow(TypeError::class, 'Native error in fallback ref'); + }); }); - test('preserves by-reference parameter mutations in wrapped callback', function () { - $registry = new TypeValidatorRegistry(); - $callable = function (int &$num): void { - $num += 50; - }; + describe('Variadic & Named Parameter Validations & Errors', function () { + test('throws TypeError on non-vendor variadic argument type error', function () { + $registry = new TypeValidatorRegistry(); + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), false, true, 'numbers', false)], + new IdentifierTypeNode('void'), + [] + ); - $typeNode = new CallableTypeNode( - new IdentifierTypeNode('callable'), - [ - new CallableTypeParameterNode( - new IdentifierTypeNode('positive-int'), - true, // isReference: true - false, - 'num', - false - ), - ], - new IdentifierTypeNode('void'), - [] - ); - - $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestByRef', $registry); - - $val = 10; - $wrapped($val); - expect($val)->toBe(60); - - // Input validation check on entry - $badVal = -5; - expect(fn () => $wrapped($badVal)) - ->toThrow(TypeError::class, 'TestByRef $num must be of type positive-int') - ; - - // Post-mutation validation check on exit - $badMutator = function (int &$num): void { - $num = -100; - }; - $wrappedBad = CallableWrapper::wrapTypeNode($typeNode, $badMutator, 'TestByRefBad', $registry); - $val2 = 10; - expect(fn () => $wrappedBad($val2)) - ->toThrow(TypeError::class, 'TestByRefBad $num must be of type positive-int') - ; + $wrapped = CallableWrapper::wrapTypeNode($typeNode, fn (...$nums) => null, 'TestVarArgErr', $registry); + + expect(fn () => $wrapped(10, -5))->toThrow(TypeError::class, 'variadic argument #2'); + }); + + test('throws TypeError on non-vendor variadic by-ref mutation error', function () { + $registry = new TypeValidatorRegistry(); + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, true, 'numbers', false)], + new IdentifierTypeNode('void'), + [] + ); + + $badMutator = function (int &...$numbers): void { + $numbers[0] = -100; + }; + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $badMutator, 'TestVarMutErr', $registry); + $val = 10; + + expect(fn () => $wrapped($val))->toThrow(TypeError::class, 'positive-int'); + }); + + test('validates by-ref parameter mutations when arguments are passed by name', function () { + $registry = new TypeValidatorRegistry(); + $threeParams = function (int &$first, string $label, int &$second): void { + $first += 10; + $second += 20; + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'first', false), + new CallableTypeParameterNode(new IdentifierTypeNode('string'), false, false, 'label', false), + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'second', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $threeParams, 'TestNamedRef', $registry); + $x = 5; + $lbl = 'hello'; + $y = 15; + $wrapped(first: $x, label: $lbl, second: $y); + + expect($x)->toBe(15)->and($y)->toBe(35); + }); }); - test('handles mixed by-ref and by-value parameters in wrapped callback', function () { - $registry = new TypeValidatorRegistry(); - $callable = function (int &$item, string $key): void { - $item *= 2; + describe('Vendor Boundary Bypasses in Callables', function () { + test('bypasses closure constraints, argument checks, mutations, and return checks when caller is vendor', function () { + $registry = new TypeValidatorRegistry(); + + $tempBase = sys_get_temp_dir() . '/typephp_vendor_runner_' . uniqid(); + $vendorDir = $tempBase . '/vendor/acme/runner'; + mkdir($vendorDir, 0777, true); + $runnerFile = $vendorDir . '/VendorCaller.php'; + + $runnerCode = <<<'PHP' +toBe(50); - }); + return $action(); + } - test('preserves variadic by-reference mutations in wrapped callback', function () { - $registry = new TypeValidatorRegistry(); - $callable = function (int &...$numbers): void { - foreach ($numbers as &$n) { - $n += 5; - } + public static function createBoundClosure(): \Closure + { + $instance = new self(); + return (function () { return $this; })->bindTo($instance, self::class); + } + + public static function createBadCallback(): \Closure + { + return function (int $x): int { return -999; }; + } + + public static function createMutator(): \Closure + { + return function (int &$a, int &...$vars): void { + $a = -10; + $vars[0] = -20; }; + } - $typeNode = new CallableTypeNode( - new IdentifierTypeNode('callable'), - [ - new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, true, 'numbers', false), - ], - new IdentifierTypeNode('void'), - [] - ); - - $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestVariadicRef', $registry); - - $a = 10; - $b = 20; - $wrapped($a, $b); - expect($a)->toBe(15) - ->and($b)->toBe(25) - ; - }); + public static function createVariadicBadCallback(): \Closure + { + return function (int ...$nums): void {}; + } +} +PHP; + file_put_contents($runnerFile, $runnerCode); + + try { + require_once $runnerFile; + + $typeClosure = new CallableTypeNode(new IdentifierTypeNode('Closure'), [], new IdentifierTypeNode('void'), []); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + $wrappedClosure = \Acme\VendorRunner\VendorCaller::wrapInVendor( + $typeClosure, + $invokable, + 'TestVendorClosure', + $registry + ); + expect($wrappedClosure)->toBeInstanceOf(Closure::class); + + $typeStaticClosure = new CallableTypeNode(new IdentifierTypeNode('static-closure'), [], new IdentifierTypeNode('void'), []); + $boundClosure = \Acme\VendorRunner\VendorCaller::createBoundClosure(); + $wrappedStatic = CallableWrapper::wrapTypeNode($typeStaticClosure, $boundClosure, 'TestVendorStatic', $registry); + expect($wrappedStatic)->toBeInstanceOf(Closure::class); + + $typeWithContracts = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), false, false, 'id', false)], + new IdentifierTypeNode('positive-int'), + [] + ); + + $badCallback = \Acme\VendorRunner\VendorCaller::createBadCallback(); + $wrapped = CallableWrapper::wrapTypeNode($typeWithContracts, $badCallback, 'TestVendorCb', $registry); + + $res = $wrapped(-5); + expect($res)->toBe(-999); + + $typeVariadic = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), false, true, 'numbers', false)], + new IdentifierTypeNode('void'), + [] + ); + $badVarCallback = \Acme\VendorRunner\VendorCaller::createVariadicBadCallback(); + $wrappedVar = CallableWrapper::wrapTypeNode($typeVariadic, $badVarCallback, 'TestVendorVar', $registry); + $wrappedVar(10, -5); - test('isCallable helper accurately identifies callables and rejects unsafe deprecated relative strings', function () { - expect(CallableWrapper::isCallable('strlen'))->toBeTrue(); - expect(CallableWrapper::isCallable(fn () => 1))->toBeTrue(); - expect(CallableWrapper::isCallable(new class () { - public function __invoke(): void - { + $typeByRef = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'ref', false), + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, true, 'vars', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $mutator = \Acme\VendorRunner\VendorCaller::createMutator(); + $wrappedMutator = CallableWrapper::wrapTypeNode($typeByRef, $mutator, 'TestVendorMut', $registry); + + $val = 5; + $var1 = 10; + $wrappedMutator($val, $var1); + expect($val)->toBe(-10); + } finally { + @unlink($runnerFile); + @rmdir($vendorDir); + @rmdir($tempBase . '/vendor/acme'); + @rmdir($tempBase . '/vendor'); + @rmdir($tempBase); + CallerBoundaryResolver::reset(); } - }))->toBeTrue(); - - expect(CallableWrapper::isCallable(''))->toBeFalse(); - expect(CallableWrapper::isCallable('static::method'))->toBeFalse(); - expect(CallableWrapper::isCallable(['static', 'method']))->toBeFalse(); - expect(CallableWrapper::isCallable(['self', 'method']))->toBeFalse(); - expect(CallableWrapper::isCallable(['parent', 'method']))->toBeFalse(); - expect(CallableWrapper::isCallable('not_callable_123'))->toBeFalse(); - expect(CallableWrapper::isCallable(123))->toBeFalse(); + }); + }); + + describe('Existing Helper Validations', function () { + test('throws TypeError when non-closure is passed to Closure type contract in non-vendor code', function () { + $registry = new TypeValidatorRegistry(); + $typeClosure = new CallableTypeNode(new IdentifierTypeNode('Closure'), [], new IdentifierTypeNode('void'), []); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + expect(fn () => CallableWrapper::wrapTypeNode($typeClosure, $invokable, 'TestNonClosure', $registry)) + ->toThrow(TypeError::class, 'TestNonClosure must be of type Closure') + ; + }); + + test('enforces static-closure constraints', function () { + $registry = new TypeValidatorRegistry(); + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('static-closure'), + [], + new IdentifierTypeNode('void'), + [] + ); + + $nonStatic = fn () => null; + + expect(fn () => CallableWrapper::wrapTypeNode($typeNode, $nonStatic, 'TestStatic', $registry)) + ->toThrow(TypeError::class, 'must be a static Closure') + ; + }); + + test('preserves by-reference parameter mutations in wrapped callback', function () { + $registry = new TypeValidatorRegistry(); + $callable = function (int &$num): void { + $num += 50; + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('positive-int'), + true, + false, + 'num', + false + ), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestByRef', $registry); + + $val = 10; + $wrapped($val); + expect($val)->toBe(60); + + $badVal = -5; + expect(fn () => $wrapped($badVal)) + ->toThrow(TypeError::class, 'TestByRef $num must be of type positive-int') + ; + + $badMutator = function (int &$num): void { + $num = -100; + }; + $wrappedBad = CallableWrapper::wrapTypeNode($typeNode, $badMutator, 'TestByRefBad', $registry); + $val2 = 10; + expect(fn () => $wrappedBad($val2)) + ->toThrow(TypeError::class, 'TestByRefBad $num must be of type positive-int') + ; + }); + + test('handles mixed by-ref and by-value parameters in wrapped callback', function () { + $registry = new TypeValidatorRegistry(); + $callable = function (int &$item, string $key): void { + $item *= 2; + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, false, 'item', false), + new CallableTypeParameterNode(new IdentifierTypeNode('string'), false, false, 'key', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestMixed', $registry); + + $num = 25; + $wrapped($num, 'key_1'); + expect($num)->toBe(50); + }); + + test('preserves variadic by-reference mutations in wrapped callback', function () { + $registry = new TypeValidatorRegistry(); + $callable = function (int &...$numbers): void { + foreach ($numbers as &$n) { + $n += 5; + } + }; + + $typeNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode(new IdentifierTypeNode('positive-int'), true, true, 'numbers', false), + ], + new IdentifierTypeNode('void'), + [] + ); + + $wrapped = CallableWrapper::wrapTypeNode($typeNode, $callable, 'TestVariadicRef', $registry); + + $a = 10; + $b = 20; + $wrapped($a, $b); + expect($a)->toBe(15) + ->and($b)->toBe(25) + ; + }); + + test('isCallable helper accurately identifies callables and rejects unsafe deprecated relative strings', function () { + expect(CallableWrapper::isCallable('strlen'))->toBeTrue(); + expect(CallableWrapper::isCallable(fn () => 1))->toBeTrue(); + expect(CallableWrapper::isCallable(new class () { + public function __invoke(): void + { + } + }))->toBeTrue(); + + expect(CallableWrapper::isCallable(''))->toBeFalse(); + expect(CallableWrapper::isCallable('static::method'))->toBeFalse(); + expect(CallableWrapper::isCallable(['static', 'method']))->toBeFalse(); + expect(CallableWrapper::isCallable(['self', 'method']))->toBeFalse(); + expect(CallableWrapper::isCallable(['parent', 'method']))->toBeFalse(); + expect(CallableWrapper::isCallable('not_callable_123'))->toBeFalse(); + expect(CallableWrapper::isCallable(123))->toBeFalse(); + }); }); -}); +}); \ No newline at end of file diff --git a/tests/Internal/Wrapper/IterableWrapperTest.php b/tests/Internal/Wrapper/IterableWrapperTest.php new file mode 100644 index 0000000..a3dfb28 --- /dev/null +++ b/tests/Internal/Wrapper/IterableWrapperTest.php @@ -0,0 +1,323 @@ + $items + */ +function fixtureValidIterableFunction(iterable $items): void {} + +/** + * @param iterable $unparameterized + */ +function fixtureBareIterableFunction(iterable $unparameterized): void {} + +/** + * @return iterable + */ +function fixtureReturnIterableFunction(): iterable +{ + return ['item1' => 10, 'item2' => 20]; +} + +describe('IterableWrapper Unit Tests', function () { + afterEach(function () { + CallerBoundaryResolver::reset(); + Config::reset(); + DocblockParser::reset(); + }); + + describe('wrap() Pipeline & Direct Fallbacks', function () { + test('returns raw value if input is not iterable', function () { + $registry = new TypeValidatorRegistry(); + $notIterable = 12345; + + $result = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureValidIterableFunction', + 'items', + $notIterable, + $registry + ); + + expect($result)->toBe(12345); + }); + + test('returns array unchanged when passed as parameter', function () { + $registry = new TypeValidatorRegistry(); + $arrayData = ['a' => 1, 'b' => 2]; + + $result = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureValidIterableFunction', + 'items', + $arrayData, + $registry + ); + + expect($result)->toBe($arrayData); + }); + + test('returns iterable untouched when function has no contract', function () { + $registry = new TypeValidatorRegistry(); + $iterator = new ArrayIterator([1, 2, 3]); + + $result = IterableWrapper::wrap( + 'nonExistentFunction123', + 'missingParam', + $iterator, + $registry + ); + + expect($result)->toBe($iterator); + }); + + test('returns iterable untouched when type is not an iterable type keyword', function () { + $registry = new TypeValidatorRegistry(); + $iterator = new ArrayIterator([1, 2, 3]); + + $ref = new ReflectionClass(DocblockParser::class); + $prop = $ref->getProperty('cache'); + $cache = $prop->getValue(); + $cache['testNonIterableFunc'] = [ + 'types' => ['arg' => new IdentifierTypeNode('int')], + 'return' => null, + 'aliases' => [], + 'templates' => [], + 'classTemplates' => [], + ]; + $prop->setValue(null, $cache); + + try { + $result = IterableWrapper::wrap('testNonIterableFunc', 'arg', $iterator, $registry); + expect($result)->toBe($iterator); + } finally { + DocblockParser::reset(); + } + }); + + test('returns iterable untouched when type has neither key nor item types', function () { + $registry = new TypeValidatorRegistry(); + $iterator = new ArrayIterator([1, 2, 3]); + + $result = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureBareIterableFunction', + 'unparameterized', + $iterator, + $registry + ); + + expect($result)->toBe($iterator); + }); + + test('wraps array returned from function into generator proxy', function () { + $registry = new TypeValidatorRegistry(); + + $wrapped = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureReturnIterableFunction', + 'return', + ['alpha' => 10, 'beta' => 20], + $registry + ); + + expect($wrapped)->toBeInstanceOf(Generator::class); + + $out = []; + foreach ($wrapped as $k => $v) { + $out[$k] = $v; + } + expect($out)->toBe(['alpha' => 10, 'beta' => 20]); + }); + + test('wraps native generator parameter directly', function () { + $registry = new TypeValidatorRegistry(); + $gen = (function () { + yield 'key1' => 10; + yield 'key2' => 20; + })(); + + $wrapped = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureValidIterableFunction', + 'items', + $gen, + $registry + ); + + expect($wrapped)->toBeInstanceOf(Generator::class); + + $collected = []; + foreach ($wrapped as $k => $v) { + $collected[$k] = $v; + } + expect($collected)->toBe(['key1' => 10, 'key2' => 20]); + }); + }); + + describe('extractKeyAndItemTypeNodes() Helper', function () { + test('extracts key and item types from type alias', function () { + $ref = new ReflectionClass(IterableWrapper::class); + $method = $ref->getMethod('extractKeyAndItemTypeNodes'); + + $aliasNode = new IdentifierTypeNode('MyIterableAlias'); + $aliasedType = new GenericTypeNode( + new IdentifierTypeNode('iterable'), + [new IdentifierTypeNode('string'), new IdentifierTypeNode('positive-int')] + ); + + $res = $method->invoke(null, $aliasNode, ['MyIterableAlias' => $aliasedType]); + + expect($res[0])->toBeInstanceOf(IdentifierTypeNode::class) + ->and((string) $res[0])->toBe('string') + ->and($res[1])->toBeInstanceOf(IdentifierTypeNode::class) + ->and((string) $res[1])->toBe('positive-int') + ; + }); + + test('extracts item type from array type node', function () { + $ref = new ReflectionClass(IterableWrapper::class); + $method = $ref->getMethod('extractKeyAndItemTypeNodes'); + + $arrayType = new ArrayTypeNode(new IdentifierTypeNode('positive-int')); + + $res = $method->invoke(null, $arrayType, []); + + expect($res[0])->toBeNull() + ->and($res[1])->toBeInstanceOf(IdentifierTypeNode::class) + ->and((string) $res[1])->toBe('positive-int') + ; + }); + + test('extracts item type from single argument generic node', function () { + $ref = new ReflectionClass(IterableWrapper::class); + $method = $ref->getMethod('extractKeyAndItemTypeNodes'); + + $singleGeneric = new GenericTypeNode( + new IdentifierTypeNode('iterable'), + [new IdentifierTypeNode('string')] + ); + + $res = $method->invoke(null, $singleGeneric, []); + + expect($res[0])->toBeNull() + ->and($res[1])->toBeInstanceOf(IdentifierTypeNode::class) + ->and((string) $res[1])->toBe('string') + ; + }); + }); + + describe('createValidationCallback() Validation & Vendor Boundary Bypasses', function () { + test('throws TypeError when yielded key violates contract', function () { + $registry = new TypeValidatorRegistry(); + $badKeyIterator = new ArrayIterator([123 => 10]); + + $wrapped = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureValidIterableFunction', + 'items', + $badKeyIterator, + $registry + ); + + expect(function () use ($wrapped) { + foreach ($wrapped as $k => $v) { + } + })->toThrow(TypeError::class, 'key must be of type string'); + }); + + test('throws TypeError when yielded value violates contract', function () { + $registry = new TypeValidatorRegistry(); + $badValueIterator = new ArrayIterator(['valid_key' => -5]); + + $wrapped = IterableWrapper::wrap( + __NAMESPACE__ . '\fixtureValidIterableFunction', + 'items', + $badValueIterator, + $registry + ); + + expect(function () use ($wrapped) { + foreach ($wrapped as $k => $v) { + } + })->toThrow(TypeError::class, 'value must be of type positive-int'); + }); + + test('bypasses key and value validation errors when caller boundary resolver should bypass function', function () { + $registry = new TypeValidatorRegistry(); + + $tempBase = sys_get_temp_dir() . '/typephp_vendor_iter_' . uniqid(); + $vendorDir = $tempBase . '/vendor/acme/runner'; + mkdir($vendorDir, 0777, true); + $runnerFile = $vendorDir . '/VendorIterableCaller.php'; + + $runnerCode = <<<'PHP' +getMethod('createValidationCallback'); + + $callback = $callbackMethod->invoke( + null, + $registry, + new IdentifierTypeNode('string'), + new IdentifierTypeNode('positive-int'), + 'Acme\\VendorIterableRunner\\VendorIterableCaller::execute(): Iterator $feed', + 'Acme\\VendorIterableRunner\\VendorIterableCaller::execute' + ); + + \Acme\VendorIterableRunner\VendorIterableCaller::execute(function () use ($callback) { + $callback(12345, 10); + $callback('valid_key', -50); + }); + + $callbackNoFunc = $callbackMethod->invoke( + null, + $registry, + new IdentifierTypeNode('string'), + new IdentifierTypeNode('positive-int'), + 'Iterator $feed', + '' + ); + + \Acme\VendorIterableRunner\VendorIterableCaller::execute(function () use ($callbackNoFunc) { + $callbackNoFunc(12345, 10); + $callbackNoFunc('valid_key', -50); + }); + + expect(true)->toBeTrue(); + } finally { + @unlink($runnerFile); + @rmdir($vendorDir); + @rmdir($tempBase . '/vendor/acme'); + @rmdir($tempBase . '/vendor'); + @rmdir($tempBase); + CallerBoundaryResolver::reset(); + } + }); + }); +}); diff --git a/tests/Internal/Wrapper/IteratorProxyTest.php b/tests/Internal/Wrapper/IteratorProxyTest.php index bd00311..07fb2ef 100644 --- a/tests/Internal/Wrapper/IteratorProxyTest.php +++ b/tests/Internal/Wrapper/IteratorProxyTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace TypePHP\Tests\Internal\Wrapper; + +use ArrayIterator; use TypePHP\Internal\Wrapper\IteratorProxy; describe('IteratorProxy Unit Tests', function () { @@ -49,6 +52,25 @@ expect($proxy->count())->toBe(5); }); + test('counts non-countable inner iterator using iterator_count fallback', function () { + $gen = (function () { + yield 1; + yield 2; + yield 3; + })(); + + $proxy = new IteratorProxy($gen, fn () => null); + + expect($proxy->count())->toBe(3); + }); + + test('returns inner iterator via getInnerIterator', function () { + $inner = new ArrayIterator(['item' => 100]); + $proxy = new IteratorProxy($inner, fn () => null); + + expect($proxy->getInnerIterator())->toBe($inner); + }); + test('forwards custom method calls to inner iterator via __call', function () { $inner = new ArrayIterator(['a' => 10]); $proxy = new IteratorProxy($inner, fn () => null); @@ -57,4 +79,4 @@ ->and($proxy->offsetExists('z'))->toBeFalse() ; }); -}); +}); \ No newline at end of file From fb9e5962569ba958008d9431c983ebbb6dca8e0f Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 23 Sep 2026 18:40:45 +0800 Subject: [PATCH 10/17] Enhance test coverage for TypeFormatter by adding tests for various data types and edge cases --- src/Internal/Diagnostic/TypeFormatter.php | 8 ++------ .../{Ast => Diagnostic}/TypeFormatterTest.php | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) rename tests/Internal/{Ast => Diagnostic}/TypeFormatterTest.php (69%) diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index 4004858..b6a0b0f 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -68,11 +68,7 @@ public static function formatGivenValue(mixed $value, bool $isSensitive = false) return "associative array (key '$firstStringKey')"; } - if ($firstNonSequentialIndex !== null) { - return "non-sequential array (index $firstNonSequentialIndex)"; - } - - return 'array (' . \count($value) . ' items)'; + return "non-sequential array (index $firstNonSequentialIndex)"; } if (\is_bool($value)) { @@ -81,4 +77,4 @@ public static function formatGivenValue(mixed $value, bool $isSensitive = false) return get_debug_type($value); } -} +} \ No newline at end of file diff --git a/tests/Internal/Ast/TypeFormatterTest.php b/tests/Internal/Diagnostic/TypeFormatterTest.php similarity index 69% rename from tests/Internal/Ast/TypeFormatterTest.php rename to tests/Internal/Diagnostic/TypeFormatterTest.php index de582f5..ecbfe0d 100644 --- a/tests/Internal/Ast/TypeFormatterTest.php +++ b/tests/Internal/Diagnostic/TypeFormatterTest.php @@ -16,6 +16,10 @@ expect(TypeFormatter::formatGivenValue(42))->toBe('int (42)'); }); +test('formats float correctly', function () { + expect(TypeFormatter::formatGivenValue(12.34))->toBe('float (12.34)'); +}); + test('formats empty string correctly', function () { expect(TypeFormatter::formatGivenValue(''))->toBe("empty string ('')"); }); @@ -41,7 +45,20 @@ expect(TypeFormatter::formatGivenValue(['id' => 1, 'name' => 'Alice']))->toBe("associative array (key 'id')"); }); +test('formats non-sequential array correctly', function () { + expect(TypeFormatter::formatGivenValue([1 => 'a', 2 => 'b']))->toBe('non-sequential array (index 1)'); +}); + test('formats booleans correctly', function () { expect(TypeFormatter::formatGivenValue(true))->toBe('bool (true)'); expect(TypeFormatter::formatGivenValue(false))->toBe('bool (false)'); }); + +test('formats sensitive parameter values without leaking content', function () { + expect(TypeFormatter::formatGivenValue('super_secret_password', isSensitive: true))->toBe('string'); + expect(TypeFormatter::formatGivenValue(12345, isSensitive: true))->toBe('int'); +}); + +test('formats object instances using get_debug_type fallback', function () { + expect(TypeFormatter::formatGivenValue(new stdClass()))->toBe('stdClass'); +}); \ No newline at end of file From b3bb9e8fa02c39b80145b7cef16e0c48123618c7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 12:47:15 +0800 Subject: [PATCH 11/17] Add new dedicated Validators Test suites tests and remove outdated ValidatorsTest --- .../Validator/ArrayShapeValidator.php | 4 +- src/Internal/Validator/ConstValidator.php | 19 +- .../Validator/ArrayShapeValidatorTest.php | 123 +++++ .../Internal/Validator/ArrayValidatorTest.php | 117 ++++ .../Internal/Validator/ConstValidatorTest.php | 118 ++++ .../Validator/GenericValidatorTest.php | 213 +++++++ .../Validator/IdentifierValidatorTest.php | 346 ++++++++++++ .../Validator/IntersectionValidatorTest.php | 225 ++++++++ .../Internal/Validator/UnionValidatorTest.php | 150 +++++ tests/Unit/ValidatorsTest.php | 520 ------------------ 10 files changed, 1300 insertions(+), 535 deletions(-) create mode 100644 tests/Internal/Validator/ArrayShapeValidatorTest.php create mode 100644 tests/Internal/Validator/ArrayValidatorTest.php create mode 100644 tests/Internal/Validator/ConstValidatorTest.php create mode 100644 tests/Internal/Validator/GenericValidatorTest.php create mode 100644 tests/Internal/Validator/IdentifierValidatorTest.php create mode 100644 tests/Internal/Validator/IntersectionValidatorTest.php create mode 100644 tests/Internal/Validator/UnionValidatorTest.php delete mode 100644 tests/Unit/ValidatorsTest.php diff --git a/src/Internal/Validator/ArrayShapeValidator.php b/src/Internal/Validator/ArrayShapeValidator.php index e28aa80..7528227 100644 --- a/src/Internal/Validator/ArrayShapeValidator.php +++ b/src/Internal/Validator/ArrayShapeValidator.php @@ -82,8 +82,6 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return ErrorFactory::createError($context . " contains unsealed unexpected key '{$k}'"); } } - - return null; } if ($shapeNode->unsealedType !== null) { @@ -111,4 +109,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} +} \ No newline at end of file diff --git a/src/Internal/Validator/ConstValidator.php b/src/Internal/Validator/ConstValidator.php index 6b790ce..cebc834 100644 --- a/src/Internal/Validator/ConstValidator.php +++ b/src/Internal/Validator/ConstValidator.php @@ -74,7 +74,6 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $expected = (string) $constExpr; } - // Float Epsilon Comparison: Handles IEEE 754 precision artifacts and int-to-float coercion if (\is_float($expected)) { if ((! \is_float($value) && ! \is_int($value)) || abs((float) $value - $expected) > 1e-9) { return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); @@ -105,20 +104,16 @@ private static function resolveWildcardConstantValues(string $className, string $values = []; if ($className !== '' && (class_exists($className) || interface_exists($className))) { - try { - $refClass = new \ReflectionClass($className); - $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; - - foreach ($refClass->getConstants() as $cName => $cValue) { - if (preg_match($regex, $cName) === 1) { - $values[] = $cValue; - } + $refClass = new \ReflectionClass($className); + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + + foreach ($refClass->getConstants() as $cName => $cValue) { + if (preg_match($regex, $cName) === 1) { + $values[] = $cValue; } - } catch (\ReflectionException $e) { - // Silently ignore reflection errors } } return self::$wildcardConstantCache[$cacheKey] = $values; } -} +} \ No newline at end of file diff --git a/tests/Internal/Validator/ArrayShapeValidatorTest.php b/tests/Internal/Validator/ArrayShapeValidatorTest.php new file mode 100644 index 0000000..1fa8248 --- /dev/null +++ b/tests/Internal/Validator/ArrayShapeValidatorTest.php @@ -0,0 +1,123 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new ArrayShapeValidator(); + }); + + test('rejects non-array inputs', function () { + $shape = ArrayShapeNode::createSealed([]); + $err = $this->validator->validate('not_array', $shape, 'payload', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type array') + ; + }); + + test('rejects associative array when shape kind is KIND_LIST', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('int')), + ], ArrayShapeNode::KIND_LIST); + + $assoc = ['non_sequential' => 10]; + $err = $this->validator->validate($assoc, $shape, 'tuple', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be a list') + ; + }); + + test('validates required and optional shape keys', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('positive-int')), + new ArrayShapeItemNode(new ConstExprStringNode('name', ConstExprStringNode::SINGLE_QUOTED), true, new IdentifierTypeNode('string')), + ]); + + expect($this->validator->validate(['id' => 10], $shape, 'user', $this->registry))->toBeNull(); + expect($this->validator->validate(['id' => 10, 'name' => 'Alice'], $shape, 'user', $this->registry))->toBeNull(); + + $err = $this->validator->validate(['name' => 'Alice'], $shape, 'user', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("is missing required key 'id'") + ; + + $errVal = $this->validator->validate(['id' => -5], $shape, 'user', $this->registry); + expect($errVal)->toBeInstanceOf(ErrorMessage::class) + ->and($errVal->getMessage())->toContain("user['id']") + ; + }); + + test('rejects unexpected keys in sealed shapes', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('int')), + ]); + + $err = $this->validator->validate(['id' => 1, 'extra' => 'forbidden'], $shape, 'data', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("contains unsealed unexpected key 'extra'") + ; + }); + + test('validates unsealed shapes with extra key and value constraints', function () { + $unsealed = new ArrayShapeUnsealedTypeNode( + new IdentifierTypeNode('string'), + new IdentifierTypeNode('non-empty-string') + ); + + $shape = ArrayShapeNode::createUnsealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('int')), + ], $unsealed); + + expect($this->validator->validate(['id' => 1, 'note' => 'extra info'], $shape, 'payload', $this->registry))->toBeNull(); + + $errVal = $this->validator->validate(['id' => 1, 'count' => 12345], $shape, 'payload', $this->registry); + expect($errVal)->toBeInstanceOf(ErrorMessage::class) + ->and($errVal->getMessage())->toContain("payload['count'] must be of type string") + ; + + $errKey = $this->validator->validate(['id' => 1, '' => 'empty_key_val'], $shape, 'payload', $this->registry); + expect($errKey)->toBeInstanceOf(ErrorMessage::class) + ->and($errKey->getMessage())->toContain("extra key ''") + ; + }); + + test('supports integer and auto-indexed keys in tuple shapes', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new IdentifierTypeNode('positive-int')), + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('string')), + ]); + + expect($this->validator->validate([10, 'hello'], $shape, 'tuple', $this->registry))->toBeNull(); + + $err = $this->validator->validate([-5, 'hello'], $shape, 'tuple', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("tuple['0']") + ; + }); + + test('supports ConstFetchNode keys in array shape items', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + new \PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode('self', 'KEY_NAME'), + false, + new IdentifierTypeNode('string') + ), + ]); + + expect($this->validator->validate(['self::KEY_NAME' => 'value'], $shape, 'payload', $this->registry))->toBeNull(); + }); +}); diff --git a/tests/Internal/Validator/ArrayValidatorTest.php b/tests/Internal/Validator/ArrayValidatorTest.php new file mode 100644 index 0000000..c833dad --- /dev/null +++ b/tests/Internal/Validator/ArrayValidatorTest.php @@ -0,0 +1,117 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new ArrayValidator(); + $this->intNode = new ArrayTypeNode(new IdentifierTypeNode('int')); + $this->posIntNode = new ArrayTypeNode(new IdentifierTypeNode('positive-int')); + }); + + afterEach(function () { + Config::reset(); + }); + + test('validates valid and empty arrays', function () { + expect($this->validator->validate([], $this->intNode, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate([1, 2, 3], $this->intNode, 'arg', $this->registry))->toBeNull() + ; + }); + + test('rejects non-array and non-traversable values', function () { + $err = $this->validator->validate('not_an_array', $this->intNode, 'arg', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type array') + ; + + $errObj = $this->validator->validate(new stdClass(), $this->intNode, 'arg', $this->registry); + expect($errObj)->toBeInstanceOf(ErrorMessage::class); + }); + + test('bypasses generator instances directly', function () { + $gen = (function () { + yield 1; + })(); + + expect($this->validator->validate($gen, $this->intNode, 'arg', $this->registry))->toBeNull(); + }); + + test('catches invalid items in exhaustive scan with string and integer keys', function () { + $badList = [1, 'invalid', 3]; + $err = $this->validator->validate($badList, $this->intNode, 'items', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('items[1]') + ; + + $badAssoc = ['first' => 10, 'second' => 'invalid']; + $errAssoc = $this->validator->validate($badAssoc, $this->intNode, 'items', $this->registry); + + expect($errAssoc)->toBeInstanceOf(ErrorMessage::class) + ->and($errAssoc->getMessage())->toContain("items['second']") + ; + }); + + test('executes hybrid sampling on large sequential lists (> 128 elements)', function () { + Config::set(['array_validation' => 'hybrid']); + + $largeList = range(1, 150); + expect($this->validator->validate($largeList, $this->posIntNode, 'large', $this->registry))->toBeNull(); + + $badBoundaryList = $largeList; + $badBoundaryList[0] = -5; + $err = $this->validator->validate($badBoundaryList, $this->posIntNode, 'large', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('large[0]') + ; + }); + + test('executes hybrid sampling on large associative arrays (> 128 elements)', function () { + Config::set(['array_validation' => 'hybrid']); + + $largeAssoc = []; + for ($i = 0; $i < 150; $i++) { + $largeAssoc["key_{$i}"] = $i + 1; + } + + expect($this->validator->validate($largeAssoc, $this->posIntNode, 'map', $this->registry))->toBeNull(); + + $badAssoc = $largeAssoc; + $badAssoc['key_0'] = -99; + $err = $this->validator->validate($badAssoc, $this->posIntNode, 'map', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("map['key_0']") + ; + }); + + test('validates non-array Traversable objects', function () { + $iterator = new ArrayIterator([10, 20, 30]); + expect($this->validator->validate($iterator, $this->intNode, 'iter', $this->registry))->toBeNull(); + + $badIterator = new ArrayIterator(['foo' => 'not_int']); + $err = $this->validator->validate($badIterator, $this->intNode, 'iter', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("iter['foo']") + ; + + $badIntKeyIterator = new ArrayIterator([0 => 'not_int']); + $errInt = $this->validator->validate($badIntKeyIterator, $this->intNode, 'iter', $this->registry); + + expect($errInt)->toBeInstanceOf(ErrorMessage::class) + ->and($errInt->getMessage())->toContain('iter[0]') + ; + }); +}); \ No newline at end of file diff --git a/tests/Internal/Validator/ConstValidatorTest.php b/tests/Internal/Validator/ConstValidatorTest.php new file mode 100644 index 0000000..23721bd --- /dev/null +++ b/tests/Internal/Validator/ConstValidatorTest.php @@ -0,0 +1,118 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new ConstValidator(); + }); + + test('validates string literals', function () { + $node = new ConstTypeNode(new ConstExprStringNode('active', ConstExprStringNode::SINGLE_QUOTED)); + + expect($this->validator->validate('active', $node, 'arg', $this->registry))->toBeNull(); + + $err = $this->validator->validate('pending', $node, 'arg', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("must be literal 'active'") + ; + }); + + test('validates boolean true and false literals', function () { + $trueNode = new ConstTypeNode(new ConstExprTrueNode()); + expect($this->validator->validate(true, $trueNode, 'arg', $this->registry))->toBeNull(); + expect($this->validator->validate(false, $trueNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $falseNode = new ConstTypeNode(new ConstExprFalseNode()); + expect($this->validator->validate(false, $falseNode, 'arg', $this->registry))->toBeNull(); + expect($this->validator->validate(true, $falseNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates null literals', function () { + $nullNode = new ConstTypeNode(new ConstExprNullNode()); + expect($this->validator->validate(null, $nullNode, 'arg', $this->registry))->toBeNull(); + expect($this->validator->validate('not_null', $nullNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates integer literals', function () { + $intNode = new ConstTypeNode(new ConstExprIntegerNode('42')); + expect($this->validator->validate(42, $intNode, 'arg', $this->registry))->toBeNull(); + expect($this->validator->validate(100, $intNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates float literals with IEEE 754 precision and int coercion', function () { + $floatNode = new ConstTypeNode(new ConstExprFloatNode('12.34')); + expect($this->validator->validate(12.34, $floatNode, 'arg', $this->registry))->toBeNull(); + expect($this->validator->validate(12.35, $floatNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $precisionNode = new ConstTypeNode(new ConstExprFloatNode('0.3')); + expect($this->validator->validate(0.1 + 0.2, $precisionNode, 'arg', $this->registry))->toBeNull(); + + $coercionNode = new ConstTypeNode(new ConstExprFloatNode('10.0')); + expect($this->validator->validate(10, $coercionNode, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate(10.0, $coercionNode, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate('not_numeric', $coercionNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates defined class constants and handles undefined constant fallback', function () { + $definedNode = new ConstTypeNode(new ConstFetchNode(SampleDefinedClassConst::class, 'VALID_STATUS')); + expect($this->validator->validate('ready', $definedNode, 'status', $this->registry))->toBeNull(); + expect($this->validator->validate('unknown', $definedNode, 'status', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $undefinedNode = new ConstTypeNode(new ConstFetchNode('NonExistentClass', 'UNDEFINED_CONST')); + expect($this->validator->validate('anything', $undefinedNode, 'status', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates global constants without class names', function () { + $globalConst = new ConstTypeNode(new ConstFetchNode('', 'PHP_VERSION_ID')); + + expect($this->validator->validate(PHP_VERSION_ID, $globalConst, 'version', $this->registry))->toBeNull(); + expect($this->validator->validate(0, $globalConst, 'version', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('handles custom or unsupported constant expression fallbacks', function () { + $arrayConstExpr = new \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode([]); + + $node = new ConstTypeNode($arrayConstExpr); + + expect($this->validator->validate('[]', $node, 'val', $this->registry))->toBeNull(); + expect($this->validator->validate('mismatch', $node, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates wildcard constant patterns', function () { + $wildcardNode = new ConstTypeNode(new ConstFetchNode(WildcardConstantFixture::class, 'VERSION_SELECTION_*')); + + expect($this->validator->validate('all', $wildcardNode, 'mode', $this->registry))->toBeNull(); + expect($this->validator->validate('blue-green', $wildcardNode, 'mode', $this->registry))->toBeNull(); + + $err = $this->validator->validate('invalid_mode', $wildcardNode, 'mode', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be a valid constant matching') + ; + + $unqualifiedWildcard = new ConstTypeNode(new ConstFetchNode('', 'PREFIX_*')); + $errUnqualified = $this->validator->validate('any', $unqualifiedWildcard, 'mode', $this->registry); + expect($errUnqualified)->toBeInstanceOf(ErrorMessage::class); + }); +}); diff --git a/tests/Internal/Validator/GenericValidatorTest.php b/tests/Internal/Validator/GenericValidatorTest.php new file mode 100644 index 0000000..f167835 --- /dev/null +++ b/tests/Internal/Validator/GenericValidatorTest.php @@ -0,0 +1,213 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new GenericValidator(); + }); + + afterEach(function () { + Config::reset(); + }); + + test('validates integer ranges with min and max bounds', function () { + $range = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new ConstExprIntegerNode('1'), + new ConstExprIntegerNode('10'), + ]); + + expect($this->validator->validate(5, $range, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(0, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(15, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate('not_int', $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $minRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new IdentifierTypeNode('min'), + new ConstExprIntegerNode('100'), + ]); + expect($this->validator->validate(-500, $minRange, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(105, $minRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $maxRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new ConstExprIntegerNode('0'), + new IdentifierTypeNode('max'), + ]); + expect($this->validator->validate(500, $maxRange, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(-1, $maxRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates class-string with union and intersection bounds', function () { + $bareClassString = new GenericTypeNode(new IdentifierTypeNode('class-string'), []); + expect($this->validator->validate(stdClass::class, $bareClassString, 'cls', $this->registry))->toBeNull(); + + $unionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ + new UnionTypeNode([new IdentifierTypeNode(DateTime::class), new IdentifierTypeNode(ArrayObject::class)]), + ]); + expect($this->validator->validate(DateTime::class, $unionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(ArrayObject::class, $unionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $unionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $intersectionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ + new IntersectionTypeNode([new IdentifierTypeNode('Countable'), new IdentifierTypeNode('ArrayAccess')]), + ]); + expect($this->validator->validate(ArrayObject::class, $intersectionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $intersectionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates key-of on nested value-of and array shapes with multiple key styles', function () { + $nestedShape = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + new GenericTypeNode(new IdentifierTypeNode('value-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('config'), false, ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('host', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new IdentifierTypeNode('int')), + ])), + ]), + ]), + ]); + + expect($this->validator->validate('host', $nestedShape, 'key', $this->registry))->toBeNull(); + expect($this->validator->validate(0, $nestedShape, 'key', $this->registry))->toBeNull(); + expect($this->validator->validate('missing', $nestedShape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $shapeWithAutoIndex = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('string')), + new ArrayShapeItemNode(new ConstFetchNode('self', 'KEY'), false, new IdentifierTypeNode('string')), + ]), + ]); + expect($this->validator->validate(0, $shapeWithAutoIndex, 'key', $this->registry))->toBeNull(); + expect($this->validator->validate('self::KEY', $shapeWithAutoIndex, 'key', $this->registry))->toBeNull(); + }); + + test('validates value-of on constant arrays and array shapes', function () { + $constValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ + new ConstTypeNode(new ConstFetchNode(DatabaseDriverMap::class, 'PUBLIC_MAP')), + ]); + expect($this->validator->validate(1, $constValueOf, 'val', $this->registry))->toBeNull(); + expect($this->validator->validate(99, $constValueOf, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $shapeValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + new ArrayShapeItemNode(new IdentifierTypeNode('role'), false, new IdentifierTypeNode('string')), + ]), + ]); + expect($this->validator->validate(10, $shapeValueOf, 'val', $this->registry))->toBeNull(); + expect($this->validator->validate('admin', $shapeValueOf, 'val', $this->registry))->toBeNull(); + expect($this->validator->validate(false, $shapeValueOf, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates int-mask and int-mask-of with constant fetches and array of flags', function () { + $intMaskConst = new GenericTypeNode(new IdentifierTypeNode('int-mask'), [ + new ConstTypeNode(new ConstFetchNode(BitmaskFlags::class, 'FLAG_READ')), + new ConstTypeNode(new ConstFetchNode(BitmaskFlags::class, 'FLAG_WRITE')), + ]); + + expect($this->validator->validate(1, $intMaskConst, 'mask', $this->registry))->toBeNull(); + expect($this->validator->validate(3, $intMaskConst, 'mask', $this->registry))->toBeNull(); + expect($this->validator->validate(8, $intMaskConst, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate('not_int', $intMaskConst, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $intMaskOfArray = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [ + new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'FLAGS_ARRAY')), + ]); + expect($this->validator->validate(7, $intMaskOfArray, 'mask', $this->registry))->toBeNull(); + expect($this->validator->validate(16, $intMaskOfArray, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $intMaskOfSingle = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [ + new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'FLAG_A')), + ]); + expect($this->validator->validate(1, $intMaskOfSingle, 'mask', $this->registry))->toBeNull(); + expect($this->validator->validate(4, $intMaskOfSingle, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates lists and non-empty-lists with exhaustive and hybrid sampling', function () { + $nonEmptyList = new GenericTypeNode(new IdentifierTypeNode('non-empty-list'), [new IdentifierTypeNode('int')]); + expect($this->validator->validate([], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(['key' => 1], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + Config::set(['array_validation' => 'hybrid']); + $largeList = range(1, 150); + $posIntList = new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('positive-int')]); + expect($this->validator->validate($largeList, $posIntList, 'list', $this->registry))->toBeNull(); + + $badLargeList = $largeList; + $badLargeList[0] = -10; + expect($this->validator->validate($badLargeList, $posIntList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates generic arrays and handles unconstrained key and value shortcuts', function () { + $nonEmptyArray = new GenericTypeNode(new IdentifierTypeNode('non-empty-array'), [new IdentifierTypeNode('int')]); + expect($this->validator->validate([], $nonEmptyArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate('not_arr', $nonEmptyArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $unconstrainedMap = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('array-key'), + new IdentifierTypeNode('mixed'), + ]); + expect($this->validator->validate(['a' => 'anything', 1 => 42], $unconstrainedMap, 'map', $this->registry))->toBeNull(); + + $singleGenericArray = new GenericTypeNode(new IdentifierTypeNode('array'), [new IdentifierTypeNode('positive-int')]); + expect($this->validator->validate([1, 2, 3], $singleGenericArray, 'arr', $this->registry))->toBeNull(); + expect($this->validator->validate([1, -2], $singleGenericArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + Config::set(['array_validation' => 'hybrid']); + $largeMap = []; + for ($i = 0; $i < 150; $i++) { + $largeMap["k_{$i}"] = $i + 1; + } + $genericMap = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('positive-int'), + ]); + expect($this->validator->validate($largeMap, $genericMap, 'map', $this->registry))->toBeNull(); + + $badLargeMap = $largeMap; + $badLargeMap['k_0'] = -50; + expect($this->validator->validate($badLargeMap, $genericMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates object generics and handles invalid syntax gracefully', function () { + $invalidSyntax = new GenericTypeNode(new IdentifierTypeNode('invalid-class!'), [new IdentifierTypeNode('int')]); + expect($this->validator->validate(new stdClass(), $invalidSyntax, 'obj', $this->registry))->toBeNull(); + + $producerDog = new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]); + expect($this->validator->validate('not_an_object', $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(new stdClass(), $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(new Producer(new Dog()), $producerDog, 'p', $this->registry))->toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/Internal/Validator/IdentifierValidatorTest.php b/tests/Internal/Validator/IdentifierValidatorTest.php new file mode 100644 index 0000000..619fb65 --- /dev/null +++ b/tests/Internal/Validator/IdentifierValidatorTest.php @@ -0,0 +1,346 @@ +registry = new TypeValidatorRegistry(); + }); + + test('validates primitives and alias keywords', function () { + $intNode = new IdentifierTypeNode('int'); + $integerNode = new IdentifierTypeNode('integer'); + expect($this->registry->validate(10, $intNode, 'arg'))->toBeNull() + ->and($this->registry->validate(20, $integerNode, 'arg'))->toBeNull() + ->and($this->registry->validate('not_int', $intNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $stringNode = new IdentifierTypeNode('string'); + expect($this->registry->validate('hello', $stringNode, 'arg'))->toBeNull() + ->and($this->registry->validate(123, $stringNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $boolNode = new IdentifierTypeNode('bool'); + $booleanNode = new IdentifierTypeNode('boolean'); + expect($this->registry->validate(true, $boolNode, 'arg'))->toBeNull() + ->and($this->registry->validate(false, $booleanNode, 'arg'))->toBeNull() + ->and($this->registry->validate(1, $boolNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $floatNode = new IdentifierTypeNode('float'); + $doubleNode = new IdentifierTypeNode('double'); + expect($this->registry->validate(1.5, $floatNode, 'arg'))->toBeNull() + ->and($this->registry->validate(2.5, $doubleNode, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $floatNode, 'arg'))->toBeNull() + ->and($this->registry->validate('str', $floatNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $trueNode = new IdentifierTypeNode('true'); + expect($this->registry->validate(true, $trueNode, 'arg'))->toBeNull() + ->and($this->registry->validate(false, $trueNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $falseNode = new IdentifierTypeNode('false'); + expect($this->registry->validate(false, $falseNode, 'arg'))->toBeNull() + ->and($this->registry->validate(true, $falseNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nullNode = new IdentifierTypeNode('null'); + expect($this->registry->validate(null, $nullNode, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $nullNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates array, list, iterable, and object aliases', function () { + $arrayNode = new IdentifierTypeNode('array'); + expect($this->registry->validate(['a' => 1], $arrayNode, 'arg'))->toBeNull() + ->and($this->registry->validate('not_arr', $arrayNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $listNode = new IdentifierTypeNode('list'); + expect($this->registry->validate([], $listNode, 'arg'))->toBeNull() + ->and($this->registry->validate([1, 2, 3], $listNode, 'arg'))->toBeNull() + ->and($this->registry->validate(['a' => 1], $listNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $iterNode = new IdentifierTypeNode('iterable'); + expect($this->registry->validate([1, 2], $iterNode, 'arg'))->toBeNull() + ->and($this->registry->validate(new ArrayIterator([1, 2]), $iterNode, 'arg'))->toBeNull() + ->and($this->registry->validate('not_iterable', $iterNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $objectAliases = ['object', 'self', 'static', 'parent', '$this']; + foreach ($objectAliases as $alias) { + $node = new IdentifierTypeNode($alias); + expect($this->registry->validate(new stdClass(), $node, 'arg'))->toBeNull() + ->and($this->registry->validate('not_obj', $node, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + } + }); + + test('validates integer refinements including non-negative and unsigned', function () { + $posInt = new IdentifierTypeNode('positive-int'); + expect($this->registry->validate(5, $posInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(-5, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $negInt = new IdentifierTypeNode('negative-int'); + expect($this->registry->validate(-5, $negInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonPosInt = new IdentifierTypeNode('non-positive-int'); + expect($this->registry->validate(0, $nonPosInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-5, $nonPosInt, 'arg'))->toBeNull() + ->and($this->registry->validate(5, $nonPosInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonNegInt = new IdentifierTypeNode('non-negative-int'); + expect($this->registry->validate(0, $nonNegInt, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $nonNegInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $nonNegInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonZeroInt = new IdentifierTypeNode('non-zero-int'); + expect($this->registry->validate(1, $nonZeroInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $nonZeroInt, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $nonZeroInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $unsignedInt = new IdentifierTypeNode('unsigned-int'); + expect($this->registry->validate(0, $unsignedInt, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $unsignedInt, 'arg'))->toBeNull() + ->and($this->registry->validate(-1, $unsignedInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates float refinements', function () { + $posFloat = new IdentifierTypeNode('positive-float'); + expect($this->registry->validate(1.5, $posFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-1.5, $posFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $negFloat = new IdentifierTypeNode('negative-float'); + expect($this->registry->validate(-2.5, $negFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(2.5, $negFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonPosFloat = new IdentifierTypeNode('non-positive-float'); + expect($this->registry->validate(-1.0, $nonPosFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(0.0, $nonPosFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(1.0, $nonPosFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonNegFloat = new IdentifierTypeNode('non-negative-float'); + expect($this->registry->validate(1.0, $nonNegFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(0.0, $nonNegFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-1.0, $nonNegFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonZeroFloat = new IdentifierTypeNode('non-zero-float'); + expect($this->registry->validate(0.5, $nonZeroFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(-0.5, $nonZeroFloat, 'arg'))->toBeNull() + ->and($this->registry->validate(0.0, $nonZeroFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates pseudo-types and control flow types', function () { + $voidNode = new IdentifierTypeNode('void'); + expect($this->registry->validate(null, $voidNode, 'arg'))->toBeNull() + ->and($this->registry->validate(1, $voidNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $neverVariants = ['never', 'never-return', 'never-returns', 'no-return']; + foreach ($neverVariants as $variant) { + $node = new IdentifierTypeNode($variant); + expect($this->registry->validate('returned', $node, 'arg'))->toBeInstanceOf(ErrorMessage::class); + } + + $mixedNode = new IdentifierTypeNode('mixed'); + expect($this->registry->validate('any', $mixedNode, 'arg'))->toBeNull() + ->and($this->registry->validate(null, $mixedNode, 'arg'))->toBeNull() + ; + + $scalarNode = new IdentifierTypeNode('scalar'); + expect($this->registry->validate('text', $scalarNode, 'arg'))->toBeNull() + ->and($this->registry->validate(123, $scalarNode, 'arg'))->toBeNull() + ->and($this->registry->validate(new stdClass(), $scalarNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $truthyNode = new IdentifierTypeNode('truthy'); + expect($this->registry->validate('yes', $truthyNode, 'arg'))->toBeNull() + ->and($this->registry->validate(0, $truthyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $falsyNode = new IdentifierTypeNode('falsy'); + $falseyNode = new IdentifierTypeNode('falsey'); + expect($this->registry->validate(0, $falsyNode, 'arg'))->toBeNull() + ->and($this->registry->validate('', $falseyNode, 'arg'))->toBeNull() + ->and($this->registry->validate('yes', $falsyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $numericNode = new IdentifierTypeNode('numeric'); + $numberNode = new IdentifierTypeNode('number'); + expect($this->registry->validate(10, $numericNode, 'arg'))->toBeNull() + ->and($this->registry->validate('12.5', $numberNode, 'arg'))->toBeNull() + ->and($this->registry->validate(3.14, $numberNode, 'arg'))->toBeNull() + ->and($this->registry->validate('abc', $numericNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates standard, open, and closed resources', function () { + $res = fopen('php://memory', 'r+'); + $resourceNode = new IdentifierTypeNode('resource'); + $openResourceNode = new IdentifierTypeNode('open-resource'); + $closedResourceNode = new IdentifierTypeNode('closed-resource'); + + expect($this->registry->validate($res, $resourceNode, 'arg'))->toBeNull() + ->and($this->registry->validate($res, $openResourceNode, 'arg'))->toBeNull() + ->and($this->registry->validate('not_resource', $resourceNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('not_resource', $openResourceNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate($res, $closedResourceNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + fclose($res); + + expect($this->registry->validate($res, $closedResourceNode, 'arg'))->toBeNull() + ->and($this->registry->validate($res, $openResourceNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('not_resource', $closedResourceNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates string class subtypes, trait-string, and callables', function () { + $classString = new IdentifierTypeNode('class-string'); + expect($this->registry->validate(stdClass::class, $classString, 'arg'))->toBeNull() + ->and($this->registry->validate('Invalid Class', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $ifaceString = new IdentifierTypeNode('interface-string'); + expect($this->registry->validate(DateTimeInterface::class, $ifaceString, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $ifaceString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $traitString = new IdentifierTypeNode('trait-string'); + expect($this->registry->validate(ExecutorTrait::class, $traitString, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $traitString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(12345, $traitString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $enumString = new IdentifierTypeNode('enum-string'); + expect($this->registry->validate(Suit::class, $enumString, 'arg'))->toBeNull() + ->and($this->registry->validate(stdClass::class, $enumString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $callableString = new IdentifierTypeNode('callable-string'); + expect($this->registry->validate('strlen', $callableString, 'arg'))->toBeNull() + ->and($this->registry->validate('non_existent_func_xyz', $callableString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $pureCallable = new IdentifierTypeNode('pure-callable'); + $callableNode = new IdentifierTypeNode('callable'); + expect($this->registry->validate('strlen', $pureCallable, 'arg'))->toBeNull() + ->and($this->registry->validate(fn() => 1, $callableNode, 'arg'))->toBeNull() + ->and($this->registry->validate(12345, $pureCallable, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates string and array refinements', function () { + $truthyString = new IdentifierTypeNode('truthy-string'); + $nonFalsyString = new IdentifierTypeNode('non-falsy-string'); + expect($this->registry->validate('yes', $truthyString, 'arg'))->toBeNull() + ->and($this->registry->validate('valid', $nonFalsyString, 'arg'))->toBeNull() + ->and($this->registry->validate('0', $truthyString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('', $nonFalsyString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyArray = new IdentifierTypeNode('non-empty-array'); + expect($this->registry->validate(['a' => 1], $nonEmptyArray, 'arg'))->toBeNull() + ->and($this->registry->validate([], $nonEmptyArray, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyList = new IdentifierTypeNode('non-empty-list'); + expect($this->registry->validate([1, 2], $nonEmptyList, 'arg'))->toBeNull() + ->and($this->registry->validate([], $nonEmptyList, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate(['key' => 1], $nonEmptyList, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $lowerStr = new IdentifierTypeNode('lowercase-string'); + expect($this->registry->validate('abc', $lowerStr, 'arg'))->toBeNull() + ->and($this->registry->validate('Abc', $lowerStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyLower = new IdentifierTypeNode('non-empty-lowercase-string'); + expect($this->registry->validate('abc', $nonEmptyLower, 'arg'))->toBeNull() + ->and($this->registry->validate('', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('Abc', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $upperStr = new IdentifierTypeNode('uppercase-string'); + expect($this->registry->validate('ABC', $upperStr, 'arg'))->toBeNull() + ->and($this->registry->validate('Abc', $upperStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $nonEmptyUpper = new IdentifierTypeNode('non-empty-uppercase-string'); + expect($this->registry->validate('ABC', $nonEmptyUpper, 'arg'))->toBeNull() + ->and($this->registry->validate('', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ->and($this->registry->validate('Abc', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $arrayKey = new IdentifierTypeNode('array-key'); + expect($this->registry->validate(10, $arrayKey, 'arg'))->toBeNull() + ->and($this->registry->validate('key', $arrayKey, 'arg'))->toBeNull() + ->and($this->registry->validate(false, $arrayKey, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + + $literalString = new IdentifierTypeNode('literal-string'); + expect($this->registry->validate('string_val', $literalString, 'arg'))->toBeNull() + ->and($this->registry->validate(10, $literalString, 'arg'))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates objects and handles class validation fallback', function () { + $dogNode = new IdentifierTypeNode(Dog::class); + + expect($this->registry->validate(new Dog(), $dogNode, 'arg'))->toBeNull(); + + $errClass = $this->registry->validate(new Car(), $dogNode, 'arg'); + expect($errClass)->toBeInstanceOf(ErrorMessage::class) + ->and($errClass->getMessage())->toContain(Dog::class) + ; + + $errNonObj = $this->registry->validate('string_val', $dogNode, 'arg'); + expect($errNonObj)->toBeInstanceOf(ErrorMessage::class); + + $invalidSyntaxNode = new IdentifierTypeNode('invalid-class-syntax!'); + expect($this->registry->validate('anything', $invalidSyntaxNode, 'arg'))->toBeNull(); + }); + + test('formats error messages respecting sensitivity redaction', function () { + $intNode = new IdentifierTypeNode('int'); + $err = $this->registry->validate('secret_val', $intNode, 'secretField', isSensitive: true); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('string given') + ->and($err->getMessage())->not()->toContain('secret_val') + ; + }); + + test('direct validator instance validation handles valid and invalid classes directly', function () { + $validator = new IdentifierValidator(); + $dogNode = new IdentifierTypeNode(Dog::class); + + expect($validator->validate(new Dog(), $dogNode, 'arg', $this->registry))->toBeNull(); + expect($validator->validate(new Car(), $dogNode, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); +}); diff --git a/tests/Internal/Validator/IntersectionValidatorTest.php b/tests/Internal/Validator/IntersectionValidatorTest.php new file mode 100644 index 0000000..1cc5936 --- /dev/null +++ b/tests/Internal/Validator/IntersectionValidatorTest.php @@ -0,0 +1,225 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new IntersectionValidator(); + }); + + test('validates objects implementing all intersected interfaces', function () { + $intersection = new IntersectionTypeNode([ + new IdentifierTypeNode('Countable'), + new IdentifierTypeNode('ArrayAccess'), + ]); + + $valid = new CountableArrayAccess(); + expect($this->validator->validate($valid, $intersection, 'collection', $this->registry))->toBeNull(); + + $onlyCountable = new CountableOnly(); + $err = $this->validator->validate($onlyCountable, $intersection, 'collection', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type (Countable & ArrayAccess)') + ; + + $onlyArrayAccess = new ArrayAccessOnly(); + $errAccess = $this->validator->validate($onlyArrayAccess, $intersection, 'collection', $this->registry); + expect($errAccess)->toBeInstanceOf(ErrorMessage::class); + }); + + test('merges two sealed array shapes in an intersection', function () { + $shapeA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('positive-int')), + ]); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('name', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('non-empty-string')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + $valid = ['id' => 10, 'name' => 'Alice']; + expect($this->validator->validate($valid, $intersection, 'data', $this->registry))->toBeNull(); + + $missingKey = ['id' => 10]; + $errMissing = $this->validator->validate($missingKey, $intersection, 'data', $this->registry); + expect($errMissing)->toBeInstanceOf(ErrorMessage::class) + ->and($errMissing->getMessage())->toContain("missing required key 'name'") + ; + + $unexpectedKey = ['id' => 10, 'name' => 'Alice', 'extra' => 'forbidden']; + $errExtra = $this->validator->validate($unexpectedKey, $intersection, 'data', $this->registry); + expect($errExtra)->toBeInstanceOf(ErrorMessage::class) + ->and($errExtra->getMessage())->toContain("contains unsealed unexpected key 'extra'") + ; + }); + + test('merges unsealed array shapes in an intersection', function () { + $unsealed = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('string'), null); + $shapeA = ArrayShapeNode::createUnsealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('int')), + ], $unsealed); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('tag', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + $validWithExtra = ['id' => 10, 'tag' => 'main', 'extra' => 'allowed_extra_string']; + expect($this->validator->validate($validWithExtra, $intersection, 'data', $this->registry))->toBeNull(); + }); + + test('merges list-kind and keyless tuple array shapes', function () { + $tupleA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new IdentifierTypeNode('int')), + ], ArrayShapeNode::KIND_LIST); + $tupleB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('positive-int')), + ], ArrayShapeNode::KIND_LIST); + $intersection = new IntersectionTypeNode([$tupleA, $tupleB]); + + expect($this->validator->validate([10, 20], $intersection, 'tuple', $this->registry))->toBeNull(); + + $err = $this->validator->validate([10, -5], $intersection, 'tuple', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("tuple['1']") + ; + }); + + test('merges overlapping keys with required and optional flags', function () { + $shapeA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), true, new IdentifierTypeNode('int')), + ]); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + expect($this->validator->validate(['id' => 10], $intersection, 'val', $this->registry))->toBeNull(); + + $err = $this->validator->validate(['id' => -10], $intersection, 'val', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("val['id']") + ; + }); + + test('merges two object shapes in an intersection', function () { + $shapeA = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + ]); + $shapeB = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('non-empty-string')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + $validObj = new IntersectionDummyUser(); + expect($this->validator->validate($validObj, $intersection, 'user', $this->registry))->toBeNull(); + + $std = new stdClass(); + $std->id = 10; + $std->name = 'Alice'; + expect($this->validator->validate($std, $intersection, 'user', $this->registry))->toBeNull(); + + $badStd = new stdClass(); + $badStd->id = 10; + $badStd->name = ''; + $err = $this->validator->validate($badStd, $intersection, 'user', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('user->name') + ; + }); + + test('extracts keys with ConstFetchNode in array shape merging', function () { + $shapeA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstFetchNode('self', 'KEY'), false, new IdentifierTypeNode('int')), + ]); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('other', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + $valid = ['self::KEY' => 10, 'other' => 'text']; + expect($this->validator->validate($valid, $intersection, 'data', $this->registry))->toBeNull(); + }); + + test('leaves single shape intersections unmerged when count is less than two', function () { + $shape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('int')), + ]); + $intersection = new IntersectionTypeNode([$shape]); + + expect($this->validator->validate(['id' => 1], $intersection, 'item', $this->registry))->toBeNull(); + }); + + test('merges array shapes while preserving non-shape types in intersection', function () { + $shapeA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('int')), + ]); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('string')), + ]); + $nonShapeType = new IdentifierTypeNode('array'); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB, $nonShapeType]); + + expect($this->validator->validate(['id' => 10, 'name' => 'Alice'], $intersection, 'data', $this->registry))->toBeNull(); + }); + + test('merges object shapes while preserving non-shape types in intersection', function () { + $shapeA = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('int')), + ]); + $shapeB = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('string')), + ]); + $nonShapeType = new IdentifierTypeNode(stdClass::class); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB, $nonShapeType]); + + $std = new stdClass(); + $std->id = 10; + $std->name = 'Alice'; + + expect($this->validator->validate($std, $intersection, 'data', $this->registry))->toBeNull(); + }); + + test('merges object shapes with overlapping properties into an intersection', function () { + $shapeA = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('score'), true, new IdentifierTypeNode('int')), + ]); + $shapeB = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('score'), false, new IdentifierTypeNode('positive-int')), + ]); + $intersection = new IntersectionTypeNode([$shapeA, $shapeB]); + + $std = new stdClass(); + $std->score = 50; + expect($this->validator->validate($std, $intersection, 'data', $this->registry))->toBeNull(); + + $badStd = new stdClass(); + $badStd->score = -10; + $err = $this->validator->validate($badStd, $intersection, 'data', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('data->score') + ; + }); +}); diff --git a/tests/Internal/Validator/UnionValidatorTest.php b/tests/Internal/Validator/UnionValidatorTest.php new file mode 100644 index 0000000..be7163a --- /dev/null +++ b/tests/Internal/Validator/UnionValidatorTest.php @@ -0,0 +1,150 @@ +registry = new TypeValidatorRegistry(); + $this->validator = new UnionValidator(); + }); + + test('validates fast-path primitive and object union members', function () { + $union = new UnionTypeNode([ + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + new IdentifierTypeNode('bool'), + new IdentifierTypeNode('null'), + new IdentifierTypeNode('object'), + new IdentifierTypeNode('class-string'), + ]); + + expect($this->validator->validate(10, $union, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate('hello', $union, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(true, $union, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(null, $union, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(new stdClass(), $union, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $union, 'val', $this->registry))->toBeNull() + ; + }); + + test('bubbles deep errors from nested array shapes', function () { + $shapeA = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('positive-int')), + ]); + $shapeB = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('code', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), + ]); + $union = new UnionTypeNode([$shapeA, $shapeB]); + + $badPayload = ['id' => -10]; + $err = $this->validator->validate($badPayload, $union, 'payload', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("payload['id']") + ; + }); + + test('selects matching discriminator branch in array shapes', function () { + $clickShape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('type', ConstExprStringNode::SINGLE_QUOTED), false, new ConstTypeNode(new ConstExprStringNode('click', ConstExprStringNode::SINGLE_QUOTED))), + new ArrayShapeItemNode(new ConstExprStringNode('x', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('positive-int')), + ]); + $scrollShape = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('type', ConstExprStringNode::SINGLE_QUOTED), false, new ConstTypeNode(new ConstExprStringNode('scroll', ConstExprStringNode::SINGLE_QUOTED))), + new ArrayShapeItemNode(new ConstExprStringNode('offset', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('positive-int')), + ]); + $union = new UnionTypeNode([$clickShape, $scrollShape]); + + expect($this->validator->validate(['type' => 'click', 'x' => 10], $union, 'event', $this->registry))->toBeNull(); + expect($this->validator->validate(['type' => 'click', 'x' => new StringableDiscriminator()], $union, 'event', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $badClick = ['type' => 'click', 'x' => -5]; + $errClick = $this->validator->validate($badClick, $union, 'event', $this->registry); + expect($errClick)->toBeInstanceOf(ErrorMessage::class) + ->and($errClick->getMessage())->toContain("event['x']") + ; + + $unmatched = ['type' => 'unknown', 'extra' => 123]; + $errUnmatched = $this->validator->validate($unmatched, $union, 'event', $this->registry); + expect($errUnmatched)->toBeInstanceOf(ErrorMessage::class) + ->and($errUnmatched->getMessage())->toContain('must be of type') + ; + }); + + test('selects matching discriminator branch in object shapes', function () { + $userShape = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('kind'), false, new ConstTypeNode(new ConstExprStringNode('user', ConstExprStringNode::SINGLE_QUOTED))), + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + ]); + $botShape = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('kind'), false, new ConstTypeNode(new ConstExprStringNode('bot', ConstExprStringNode::SINGLE_QUOTED))), + new ObjectShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('non-empty-string')), + ]); + $union = new UnionTypeNode([$userShape, $botShape]); + + $validUser = (object)['kind' => 'user', 'id' => 10]; + expect($this->validator->validate($validUser, $union, 'actor', $this->registry))->toBeNull(); + + $badUser = (object)['kind' => 'user', 'id' => -10]; + $errUser = $this->validator->validate($badUser, $union, 'actor', $this->registry); + expect($errUser)->toBeInstanceOf(ErrorMessage::class) + ->and($errUser->getMessage())->toContain('actor->id') + ; + + $badBot = (object)['kind' => 'bot', 'name' => '']; + $errBot = $this->validator->validate($badBot, $union, 'actor', $this->registry); + expect($errBot)->toBeInstanceOf(ErrorMessage::class) + ->and($errBot->getMessage())->toContain('actor->name') + ; + }); + + test('supports integer literal discriminators in array shapes', function () { + $eventOne = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new ConstTypeNode(new ConstExprIntegerNode('1'))), + new ArrayShapeItemNode(new ConstExprIntegerNode('1'), false, new IdentifierTypeNode('positive-int')), + ]); + $eventTwo = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new ConstTypeNode(new ConstExprIntegerNode('2'))), + new ArrayShapeItemNode(new ConstExprIntegerNode('1'), false, new IdentifierTypeNode('string')), + ]); + $union = new UnionTypeNode([$eventOne, $eventTwo]); + + expect($this->validator->validate([1, 10], $union, 'tuple', $this->registry))->toBeNull(); + expect($this->validator->validate([2, 'text'], $union, 'tuple', $this->registry))->toBeNull(); + + $err = $this->validator->validate([1, -10], $union, 'tuple', $this->registry); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("tuple['1']") + ; + }); + + test('validates fast-path class-string in union without string type', function () { + $union = new UnionTypeNode([ + new IdentifierTypeNode('int'), + new IdentifierTypeNode('class-string'), + ]); + + expect($this->validator->validate(stdClass::class, $union, 'val', $this->registry))->toBeNull(); + }); +}); diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php deleted file mode 100644 index 2c1dd87..0000000 --- a/tests/Unit/ValidatorsTest.php +++ /dev/null @@ -1,520 +0,0 @@ -registry = new TypeValidatorRegistry(); - $config = new ParserConfig(usedAttributes: []); - $this->lexer = new Lexer($config); - $constExprParser = new ConstExprParser($config); - $this->typeParser = new TypeParser($config, $constExprParser); -}); - -function parseType(string $typeString, Lexer $lexer, TypeParser $typeParser): TypeNode -{ - $tokens = new TokenIterator($lexer->tokenize($typeString)); - - return $typeParser->parse($tokens); -} - -describe('IdentifierValidator', function () { - test('validates case-insensitive refinement types (Positive-Int, Non-Empty-String)', function () { - $posInt = parseType('Positive-Int', $this->lexer, $this->typeParser); - - expect($this->registry->validate(5, $posInt, 'arg'))->toBeNull(); - expect($this->registry->validate(-5, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); - - test('validates basic primitives', function () { - $intNode = parseType('int', $this->lexer, $this->typeParser); - expect($this->registry->validate(10, $intNode, 'arg'))->toBeNull() - ->and($this->registry->validate('hello', $intNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $stringNode = parseType('string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $stringNode, 'arg'))->toBeNull() - ->and($this->registry->validate(123, $stringNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $boolNode = parseType('bool', $this->lexer, $this->typeParser); - expect($this->registry->validate(true, $boolNode, 'arg'))->toBeNull() - ->and($this->registry->validate(false, $boolNode, 'arg'))->toBeNull() - ->and($this->registry->validate('true', $boolNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $floatNode = parseType('float', $this->lexer, $this->typeParser); - expect($this->registry->validate(12.34, $floatNode, 'arg'))->toBeNull() - ->and($this->registry->validate(10, $floatNode, 'arg'))->toBeNull() // Int coerced to float - ->and($this->registry->validate('not_float', $floatNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates int refinements (positive, negative, non-positive, non-negative, non-zero, unsigned)', function () { - $posInt = parseType('positive-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(5, $posInt, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(-5, $posInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $negInt = parseType('negative-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(-5, $negInt, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(5, $negInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonPosInt = parseType('non-positive-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $nonPosInt, 'arg'))->toBeNull() - ->and($this->registry->validate(-5, $nonPosInt, 'arg'))->toBeNull() - ->and($this->registry->validate(5, $nonPosInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonNegInt = parseType('non-negative-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $nonNegInt, 'arg'))->toBeNull() - ->and($this->registry->validate(5, $nonNegInt, 'arg'))->toBeNull() - ->and($this->registry->validate(-5, $nonNegInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonZeroInt = parseType('non-zero-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(1, $nonZeroInt, 'arg'))->toBeNull() - ->and($this->registry->validate(-1, $nonZeroInt, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $nonZeroInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $unsignedInt = parseType('unsigned-int', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $unsignedInt, 'arg'))->toBeNull() - ->and($this->registry->validate(10, $unsignedInt, 'arg'))->toBeNull() - ->and($this->registry->validate(-1, $unsignedInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates float refinements (positive, negative, non-positive, non-negative, non-zero)', function () { - $posFloat = parseType('positive-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(12.34, $posFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(-12.34, $posFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $negFloat = parseType('negative-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(-5.5, $negFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(5.5, $negFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonPosFloat = parseType('non-positive-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(0.0, $nonPosFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(-5.5, $nonPosFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(5.5, $nonPosFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonNegFloat = parseType('non-negative-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(0.0, $nonNegFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(5.5, $nonNegFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(-5.5, $nonNegFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonZeroFloat = parseType('non-zero-float', $this->lexer, $this->typeParser); - expect($this->registry->validate(1.5, $nonZeroFloat, 'arg'))->toBeNull() - ->and($this->registry->validate(0.0, $nonZeroFloat, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates string refinements', function () { - $nonEmpty = parseType('non-empty-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $nonEmpty, 'arg'))->toBeNull() - ->and($this->registry->validate('', $nonEmpty, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $numericStr = parseType('numeric-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('123.45', $numericStr, 'arg'))->toBeNull() - ->and($this->registry->validate('not_a_number', $numericStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $lowerStr = parseType('lowercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $lowerStr, 'arg'))->toBeNull() - ->and($this->registry->validate('Hello', $lowerStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonEmptyLower = parseType('non-empty-lowercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $nonEmptyLower, 'arg'))->toBeNull() - ->and($this->registry->validate('', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate('Hello', $nonEmptyLower, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $upperStr = parseType('uppercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('USD', $upperStr, 'arg'))->toBeNull() - ->and($this->registry->validate('', $upperStr, 'arg'))->toBeNull() - ->and($this->registry->validate('Usd', $upperStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nonEmptyUpper = parseType('non-empty-uppercase-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('EUR', $nonEmptyUpper, 'arg'))->toBeNull() - ->and($this->registry->validate('', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate('eur', $nonEmptyUpper, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $arrayKey = parseType('array-key', $this->lexer, $this->typeParser); - expect($this->registry->validate(123, $arrayKey, 'arg'))->toBeNull() - ->and($this->registry->validate('key_1', $arrayKey, 'arg'))->toBeNull() - ->and($this->registry->validate(true, $arrayKey, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $literalStr = parseType('literal-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('any_string', $literalStr, 'arg'))->toBeNull() - ->and($this->registry->validate(123, $literalStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $truthyStr = parseType('truthy-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('hello', $truthyStr, 'arg'))->toBeNull() - ->and($this->registry->validate('0', $truthyStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate('', $truthyStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates pseudo-types (mixed, scalar, void, never, truthy, falsy, numeric, resources)', function () { - $mixed = parseType('mixed', $this->lexer, $this->typeParser); - expect($this->registry->validate(123, $mixed, 'arg'))->toBeNull() - ->and($this->registry->validate(null, $mixed, 'arg'))->toBeNull() - ->and($this->registry->validate(new stdClass(), $mixed, 'arg'))->toBeNull() - ; - - $scalar = parseType('scalar', $this->lexer, $this->typeParser); - expect($this->registry->validate(123, $scalar, 'arg'))->toBeNull() - ->and($this->registry->validate('hello', $scalar, 'arg'))->toBeNull() - ->and($this->registry->validate(true, $scalar, 'arg'))->toBeNull() - ->and($this->registry->validate([], $scalar, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $void = parseType('void', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $void, 'arg'))->toBeNull() - ->and($this->registry->validate(123, $void, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $never = parseType('never', $this->lexer, $this->typeParser); - expect($this->registry->validate('returned', $never, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $truthy = parseType('truthy', $this->lexer, $this->typeParser); - expect($this->registry->validate('true', $truthy, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $truthy, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $falsy = parseType('falsy', $this->lexer, $this->typeParser); - expect($this->registry->validate(false, $falsy, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $falsy, 'arg'))->toBeNull() - ->and($this->registry->validate('hello', $falsy, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $numeric = parseType('numeric', $this->lexer, $this->typeParser); - expect($this->registry->validate(10, $numeric, 'arg'))->toBeNull() - ->and($this->registry->validate('10.5', $numeric, 'arg'))->toBeNull() - ->and($this->registry->validate('not_numeric', $numeric, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $res = fopen('php://memory', 'r+'); - $openResource = parseType('open-resource', $this->lexer, $this->typeParser); - expect($this->registry->validate($res, $openResource, 'arg'))->toBeNull(); - fclose($res); - - $closedResource = parseType('closed-resource', $this->lexer, $this->typeParser); - expect($this->registry->validate($res, $closedResource, 'arg'))->toBeNull(); - }); - - test('validates string class type identifiers (class-string, interface-string, trait-string, enum-string, callable-string)', function () { - $classString = parseType('class-string', $this->lexer, $this->typeParser); - expect($this->registry->validate(DateTimeInterface::class, $classString, 'arg'))->toBeNull() - ->and($this->registry->validate(Dog::class, $classString, 'arg'))->toBeNull() - ->and($this->registry->validate('Invalid Class Name', $classString, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $ifaceString = parseType('interface-string', $this->lexer, $this->typeParser); - expect($this->registry->validate(DateTimeInterface::class, $ifaceString, 'arg'))->toBeNull() - ->and($this->registry->validate(stdClass::class, $ifaceString, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $enumString = parseType('enum-string', $this->lexer, $this->typeParser); - expect($this->registry->validate(StatusEnum::class, $enumString, 'arg'))->toBeNull() - ->and($this->registry->validate(stdClass::class, $enumString, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $callableStr = parseType('callable-string', $this->lexer, $this->typeParser); - expect($this->registry->validate('strlen', $callableStr, 'arg'))->toBeNull() - ->and($this->registry->validate('non_existent_func_123', $callableStr, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates class instances and ignores invalid custom syntax with hyphens', function () { - $dogNode = parseType(Dog::class, $this->lexer, $this->typeParser); - expect($this->registry->validate(new Dog(), $dogNode, 'arg'))->toBeNull() - ->and($this->registry->validate(new Car(), $dogNode, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $hyphenSyntax = parseType('custom-type-with-hyphens', $this->lexer, $this->typeParser); - expect($this->registry->validate('anything', $hyphenSyntax, 'arg'))->toBeNull(); - }); - - test('validates class-string with union bounds (class-string)', function () { - $unionClassString = parseType('class-string<' . Dog::class . '|' . Cat::class . '>', $this->lexer, $this->typeParser); - - expect($this->registry->validate(Dog::class, $unionClassString, 'arg'))->toBeNull(); - expect($this->registry->validate(Cat::class, $unionClassString, 'arg'))->toBeNull(); - expect($this->registry->validate(Car::class, $unionClassString, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); -}); - -describe('ConstValidator', function () { - test('validates string, integer, bool, and null literals', function () { - $strLiteral = parseType("'active'", $this->lexer, $this->typeParser); - expect($this->registry->validate('active', $strLiteral, 'arg'))->toBeNull() - ->and($this->registry->validate('inactive', $strLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $intLiteral = parseType('42', $this->lexer, $this->typeParser); - expect($this->registry->validate(42, $intLiteral, 'arg'))->toBeNull() - ->and($this->registry->validate(100, $intLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $trueLiteral = parseType('true', $this->lexer, $this->typeParser); - expect($this->registry->validate(true, $trueLiteral, 'arg'))->toBeNull() - ->and($this->registry->validate(1, $trueLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $nullLiteral = parseType('null', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $nullLiteral, 'arg'))->toBeNull() - ->and($this->registry->validate(false, $nullLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates float literals and handles IEEE 754 precision', function () { - $floatLiteral = parseType('12.34', $this->lexer, $this->typeParser); - expect($this->registry->validate(12.34, $floatLiteral, 'arg'))->toBeNull() - ->and($this->registry->validate(12.35, $floatLiteral, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $precisionLiteral = parseType('0.3', $this->lexer, $this->typeParser); - $sum = 0.1 + 0.2; // Evaluates to 0.30000000000000004 in IEEE 754 - expect($this->registry->validate($sum, $precisionLiteral, 'arg'))->toBeNull(); - }); - - test('validates wildcard class constant patterns', function () { - $wildcardType = parseType(WildcardConstantFixture::class . '::VERSION_SELECTION_*', $this->lexer, $this->typeParser); - expect($this->registry->validate('all', $wildcardType, 'arg'))->toBeNull() - ->and($this->registry->validate('blue-green', $wildcardType, 'arg'))->toBeNull() - ->and($this->registry->validate('internal-mode', $wildcardType, 'arg'))->toBeNull() - ->and($this->registry->validate('invalid', $wildcardType, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); -}); - -describe('GenericValidator', function () { - test('validates int range bounds', function () { - $range = parseType('int<1, 10>', $this->lexer, $this->typeParser); - expect($this->registry->validate(5, $range, 'arg'))->toBeNull() - ->and($this->registry->validate(0, $range, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(15, $range, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $minRange = parseType('int', $this->lexer, $this->typeParser); - expect($this->registry->validate(-99999, $minRange, 'arg'))->toBeNull() - ->and($this->registry->validate(101, $minRange, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $maxRange = parseType('int<0, max>', $this->lexer, $this->typeParser); - expect($this->registry->validate(999999, $maxRange, 'arg'))->toBeNull() - ->and($this->registry->validate(-1, $maxRange, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates class-string with bounds', function () { - $classStringBound = parseType('class-string<' . DateTimeInterface::class . '>', $this->lexer, $this->typeParser); - expect($this->registry->validate(DateTime::class, $classStringBound, 'arg'))->toBeNull() - ->and($this->registry->validate(DateTimeImmutable::class, $classStringBound, 'arg'))->toBeNull() - ->and($this->registry->validate(stdClass::class, $classStringBound, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates key-of on Constants, Enums, and Shapes', function () { - $constKeyOf = parseType('key-of<' . DatabaseDriverMap::class . '::PUBLIC_MAP>', $this->lexer, $this->typeParser); - expect($this->registry->validate('read', $constKeyOf, 'arg'))->toBeNull() - ->and($this->registry->validate('write', $constKeyOf, 'arg'))->toBeNull() - ->and($this->registry->validate('invalid', $constKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $enumKeyOf = parseType('key-of<' . Suit::class . '>', $this->lexer, $this->typeParser); - expect($this->registry->validate('Hearts', $enumKeyOf, 'arg'))->toBeNull() - ->and($this->registry->validate('invalid_case', $enumKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $shapeKeyOf = parseType('key-of', $this->lexer, $this->typeParser); - expect($this->registry->validate('id', $shapeKeyOf, 'arg'))->toBeNull() - ->and($this->registry->validate('name', $shapeKeyOf, 'arg'))->toBeNull() - ->and($this->registry->validate('missing_key', $shapeKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates value-of on Constants, BackedEnums, and UnitEnums', function () { - $constValueOf = parseType('value-of<' . DatabaseDriverMap::class . '::PUBLIC_MAP>', $this->lexer, $this->typeParser); - expect($this->registry->validate(1, $constValueOf, 'arg'))->toBeNull() - ->and($this->registry->validate(99, $constValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $enumValueOf = parseType('value-of<' . TransactionStatus::class . '>', $this->lexer, $this->typeParser); - expect($this->registry->validate(1, $enumValueOf, 'arg'))->toBeNull() - ->and($this->registry->validate(99, $enumValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $unitEnumValueOf = parseType('value-of<' . Suit::class . '>', $this->lexer, $this->typeParser); - expect($this->registry->validate('Hearts', $unitEnumValueOf, 'arg'))->toBeInstanceOf(ErrorMessage::class); // UnitEnums have no backing values - }); - - test('validates int-mask and int-mask-of bitmasks', function () { - $intMask = parseType('int-mask<1, 2, 4>', $this->lexer, $this->typeParser); - expect($this->registry->validate(0, $intMask, 'arg'))->toBeNull() - ->and($this->registry->validate(1, $intMask, 'arg'))->toBeNull() - ->and($this->registry->validate(3, $intMask, 'arg'))->toBeNull() - ->and($this->registry->validate(8, $intMask, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $intMaskOf = parseType('int-mask-of<' . BitmaskFlags::class . '::FLAG_*>', $this->lexer, $this->typeParser); - expect($this->registry->validate(3, $intMaskOf, 'arg'))->toBeNull() - ->and($this->registry->validate(16, $intMaskOf, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates generic lists and key-value arrays', function () { - $list = parseType('list', $this->lexer, $this->typeParser); - expect($this->registry->validate([1, 2, 3], $list, 'arg'))->toBeNull() - ->and($this->registry->validate(['key' => 1], $list, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $map = parseType('array', $this->lexer, $this->typeParser); - expect($this->registry->validate(['a' => 10], $map, 'arg'))->toBeNull() - ->and($this->registry->validate([0 => 10], $map, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(['a' => 'invalid'], $map, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates object generics (Producer)', function () { - $producerDog = parseType(Producer::class . '<' . Dog::class . '>', $this->lexer, $this->typeParser); - expect($this->registry->validate(new Producer(new Dog()), $producerDog, 'arg'))->toBeNull() - ->and($this->registry->validate(new Producer(new Car()), $producerDog, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate('not_an_object', $producerDog, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); -}); - -describe('ArrayShapeValidator & ObjectShapeValidator', function () { - test('validates array shapes with required, optional, sealed, and unsealed keys', function () { - $shape = parseType('array{id: int, name: string, active?: bool}', $this->lexer, $this->typeParser); - expect($this->registry->validate(['id' => 1, 'name' => 'Alice', 'active' => true], $shape, 'arg'))->toBeNull() - ->and($this->registry->validate(['id' => 1, 'name' => 'Alice'], $shape, 'arg'))->toBeNull() - ->and($this->registry->validate(['id' => 1], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(['id' => 1, 'name' => 'Alice', 'extra' => 1], $shape, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $unsealedShape = parseType('array{id: int, ...}', $this->lexer, $this->typeParser); - expect($this->registry->validate(['id' => 1, 'role' => 'admin'], $unsealedShape, 'arg'))->toBeNull() - ->and($this->registry->validate(['id' => 1, 'role' => 999], $unsealedShape, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates object shapes on stdClass (fast-path) and custom objects', function () { - $objShape = parseType('object{id: int, name: string}', $this->lexer, $this->typeParser); - - $std = new stdClass(); - $std->id = 1; - $std->name = 'Alice'; - expect($this->registry->validate($std, $objShape, 'arg'))->toBeNull(); - - $badStd = new stdClass(); - $badStd->id = 1; - expect($this->registry->validate($badStd, $objShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - - $custom = new UserObjectShape(1, 'Alice'); - expect($this->registry->validate($custom, $objShape, 'arg'))->toBeNull(); - - $uninit = new UninitializedReadonlyContainer(); - expect($this->registry->validate($uninit, $objShape, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); -}); - -describe('ArrayValidator, UnionValidator, NullableValidator & IntersectionValidator', function () { - test('validates typed arrays (Type[])', function () { - $intArray = parseType('int[]', $this->lexer, $this->typeParser); - expect($this->registry->validate([1, 2, 3], $intArray, 'arg'))->toBeNull() - ->and($this->registry->validate([], $intArray, 'arg'))->toBeNull() - ->and($this->registry->validate([1, 'bad', 3], $intArray, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate('not_array', $intArray, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates key-of on implicit keyless tuple shapes (key-of)', function () { - $tupleKeyOf = parseType('key-of', $this->lexer, $this->typeParser); - - expect($this->registry->validate(0, $tupleKeyOf, 'arg'))->toBeNull(); - expect($this->registry->validate(1, $tupleKeyOf, 'arg'))->toBeNull(); - - expect($this->registry->validate(2, $tupleKeyOf, 'arg'))->toBeInstanceOf(ErrorMessage::class); - }); - - test('validates nullable types (?Type)', function () { - $nullableInt = parseType('?int', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $nullableInt, 'arg'))->toBeNull() - ->and($this->registry->validate(10, $nullableInt, 'arg'))->toBeNull() - ->and($this->registry->validate('str', $nullableInt, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates union types with deep error bubbling', function () { - $union = parseType('int|string', $this->lexer, $this->typeParser); - expect($this->registry->validate(10, $union, 'arg'))->toBeNull() - ->and($this->registry->validate('str', $union, 'arg'))->toBeNull() - ->and($this->registry->validate(true, $union, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - - $deepUnion = parseType('array{id: int, tags: list}|null', $this->lexer, $this->typeParser); - expect($this->registry->validate(null, $deepUnion, 'arg'))->toBeNull() - ->and($this->registry->validate(['id' => 10, 'tags' => ['a', 'b']], $deepUnion, 'arg'))->toBeNull() - ->and($this->registry->validate(['id' => 10, 'tags' => ['a', 123]], $deepUnion, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('validates intersection types', function () { - $intersection = parseType('Countable&ArrayAccess', $this->lexer, $this->typeParser); - expect($this->registry->validate(new CountableArrayAccess(), $intersection, 'arg'))->toBeNull() - ->and($this->registry->validate(new CountableOnly(), $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ->and($this->registry->validate(new ArrayAccessOnly(), $intersection, 'arg'))->toBeInstanceOf(ErrorMessage::class) - ; - }); - - test('memoizes object validations in TypeValidatorRegistry', function () { - $dog = new Dog(); - $dogType = parseType(Dog::class, $this->lexer, $this->typeParser); - - expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); - expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); - - TypeValidatorRegistry::reset(); - expect($this->registry->validate($dog, $dogType, 'arg'))->toBeNull(); - }); -}); From 3ea6a3949704488ef9d3797373a1d1e7b46a2657 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:03:23 +0800 Subject: [PATCH 12/17] Add comprehensive tests for GenericValidator and ObjectShapeValidator - Enhanced GenericValidatorTest with additional test cases for validating key-of, value-of, and various array shapes, including support for global constants and enums. - Introduced ObjectShapeValidatorTest to validate object shapes, including handling of required and optional properties, uninitialized properties, and magic getters. - Improved type validation for class-string and int-mask types, ensuring proper error handling for invalid inputs. - Added tests for multi-template and variance scenarios in TypePHPTest to ensure correct handling of generics. --- src/Internal/Validator/GenericValidator.php | 52 +-- tests/Internal/RuntimeTypeCheckerTest.php | 414 +++++++++++++++++- .../Validator/GenericValidatorTest.php | 394 +++++++++++++---- .../Validator/ObjectShapeValidatorTest.php | 143 ++++++ tests/Unit/TypePHPTest.php | 66 ++- 5 files changed, 929 insertions(+), 140 deletions(-) create mode 100644 tests/Internal/Validator/ObjectShapeValidatorTest.php diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 479ea90..9d5002b 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -91,13 +91,9 @@ private function resolveConstantValue(string $fqcn, string $constName): mixed $constValue = false; if ($fqcn !== '') { if (class_exists($fqcn) || interface_exists($fqcn)) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - } - } catch (\ReflectionException $e) { - // Silently ignore reflection errors + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); } } } else { @@ -156,7 +152,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont $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()); + self::$enumKeyCache[$enumClass] = array_map(fn($case) => $case->name, $enumClass::cases()); } if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { @@ -240,7 +236,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co 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()); + self::$enumValueCache[$enumClass] = array_map(fn($case) => $case->value, $enumClass::cases()); } if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { @@ -310,33 +306,29 @@ private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $ $pattern = $constExpr->name; if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); + $refClass = new \ReflectionClass($fqcn); - 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($fqcn, $pattern); - if (\is_int($cValue)) { + 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; - } elseif (\is_array($cValue)) { - foreach ($cValue as $item) { - if (\is_int($item)) { - $allowedMask |= $item; - $foundFlags = true; - } + } + } + } else { + $cValue = $this->resolveConstantValue($fqcn, $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; } } } - } catch (\ReflectionException $e) { - // Silently ignore reflection errors } } } diff --git a/tests/Internal/RuntimeTypeCheckerTest.php b/tests/Internal/RuntimeTypeCheckerTest.php index 54348ab..46ab4ec 100644 --- a/tests/Internal/RuntimeTypeCheckerTest.php +++ b/tests/Internal/RuntimeTypeCheckerTest.php @@ -2,14 +2,81 @@ declare(strict_types=1); +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use TypePHP\Exception\TypeError; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\RuntimeTypeChecker; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Validator\TypeValidatorRegistry; +use TypePHP\Tests\Fixtures\Domain\Cat; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\Container; +use TypePHP\Tests\Fixtures\Services\UserService; use TypePHP\Tests\Fixtures\Types\ConfiguredProperty; +class StaticPropFixture +{ + /** + * @var positive-int + */ + public static int $count = 10; + + /** + * @var positive-int + */ + public static int $badCount = -5; + + /** + * @var positive-int + */ + public static int $disabledCount = -50; +} + +class ScopeTestService +{ + /** + * @param positive-int $id + */ + public function execute(int $id): int + { + return $id; + } +} + +use TypePHP\Internal\Checker\ParamChecker; +use TypePHP\Internal\Checker\SelfOutChecker; +use TypePHP\Internal\Docblock\DocblockParser; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; + +class RuntimeCheckerIgnoredCaller +{ + /** + * @typephp-ignore + */ + public static function run(callable $fn): mixed + { + return $fn(); + } +} + +/** + * @param mixed $a + */ +function runtimeUnconstrainedParams(mixed $a): void {} + +/** + * @return mixed + */ +function runtimeUnconstrainedReturn(): mixed +{ + return 100; +} + describe('RuntimeTypeChecker Unit Tests', function () { beforeEach(function () { Config::reset(); + RuntimeTypeChecker::reset(); Config::set([ 'inline_vars' => [ 'properties' => true, @@ -24,17 +91,57 @@ afterEach(function () { Config::reset(); + RuntimeTypeChecker::reset(); }); - test('checkVariable validates scalar types', function () { + test('isEnabled reflects global configuration', function () { + expect(RuntimeTypeChecker::isEnabled())->toBeTrue(); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::isEnabled())->toBeFalse(); + }); + + test('withPendingGeneric pushes and pops instantiation context', function () { + $instantiated = RuntimeTypeChecker::withPendingGeneric( + Container::class . '<' . Dog::class . '>', + fn() => new Container(new Dog()), + __FILE__ + ); + + expect($instantiated)->toBeInstanceOf(Container::class); + + Config::set(['enabled' => false]); + $bypass = RuntimeTypeChecker::withPendingGeneric('any', fn() => 123); + expect($bypass)->toBe(123); + }); + + test('checkStaticProperty validates static property and memoizes check', function () { + StaticPropFixture::$count = 10; + $val = RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'count', 10, __FILE__, 1); + expect($val)->toBe(10); + + $cached = RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'count', 10, __FILE__, 1); + expect($cached)->toBe(10); + + expect(fn() => RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'badCount', -5, __FILE__, 1)) + ->toThrow(TypeError::class, 'positive-int'); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'disabledCount', -50))->toBe(-50); + }); + + test('checkVariable validates scalar types and handles disabled switch', function () { $valid = RuntimeTypeChecker::checkVariable(10, 'positive-int', 'age', __FILE__); expect($valid)->toBe(10); $invalid = RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'age', __FILE__); expect($invalid)->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'age', __FILE__))->toBe(-5); }); - test('checkProperty validates property assignments', function () { + test('checkProperty validates property assignments and handles disabled switch', function () { $fixture = new ConfiguredProperty(); $valid = RuntimeTypeChecker::checkProperty([1, 2, 3], $fixture, 'numbers', __FILE__); @@ -42,31 +149,300 @@ $invalid = RuntimeTypeChecker::checkProperty(['a'], $fixture, 'numbers', __FILE__); expect($invalid)->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkProperty(['a'], $fixture, 'numbers', __FILE__))->toBe(['a']); }); - test('checkSend returns send value or validates generator TSend', function () { - $nullResult = RuntimeTypeChecker::checkSend('nonExistentFunc', null); - expect($nullResult)->toBeNull(); + test('bindInstanceFromNode delegates to TemplateManager and respects disabled switch', function () { + $dog = new Container(new Dog()); + $node = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Dog::class)]); + + expect(RuntimeTypeChecker::bindInstanceFromNode($dog, $node))->toBeNull(); + + $badNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Cat::class)]); + expect(RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode))->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode))->toBeNull(); }); - test('checkYield returns yielded value', function () { - $val = RuntimeTypeChecker::checkYield('nonExistentFunc', 'key', 'value'); - expect($val)->toBe('value'); + test('setupScope validates parameters and handles disabled switch', function () { + $service = new ScopeTestService(); + $target = ScopeTestService::class . '::execute'; + + expect(RuntimeTypeChecker::setupScope($target, ['id' => 10], $service))->toBeNull(); + + $err = RuntimeTypeChecker::setupScope($target, ['id' => -10], $service); + expect($err)->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::setupScope($target, ['id' => -10], $service))->toBeNull(); }); - test('checkParamOut validates value against post-condition contract', function () { - $valid = RuntimeTypeChecker::checkParamOut( - 'TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', - 'id', - 42 + test('checkParams validates method contracts directly and handles disabled switch', function () { + $target = UserService::class . '::find'; + $user = new UserService(); + + expect(RuntimeTypeChecker::checkParams($target, ['id' => 10], $user))->toBeNull(); + + $err = RuntimeTypeChecker::checkParams($target, ['id' => -1], $user); + expect($err)->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkParams($target, ['id' => -1], $user))->toBeNull(); + }); + + test('checkParamOut validates post-conditions and handles disabled switch', function () { + $target = 'TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture'; + + expect(RuntimeTypeChecker::checkParamOut($target, 'id', 42))->toBeNull(); + + $err = RuntimeTypeChecker::checkParamOut($target, 'id', -50); + expect($err)->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkParamOut($target, 'id', -50))->toBeNull(); + }); + + test('checkSelfOut executes state transition and handles disabled switch', function () { + $session = new \TypePHP\Tests\TypeChecking\Generics\FixtureSession(); + $target = \TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login'; + + RuntimeTypeChecker::checkSelfOut($target, $session); + expect(true)->toBeTrue(); + + Config::set(['enabled' => false]); + RuntimeTypeChecker::checkSelfOut($target, $session); + expect(true)->toBeTrue(); + }); + + test('checkReturn validates return types and handles disabled switch', function () { + $target = UserService::class . '::find'; + $user = new UserService(); + + $valid = ['id' => 10, 'name' => 'Alice']; + expect(RuntimeTypeChecker::checkReturn($target, $valid, $user))->toBe($valid); + + $invalid = ['id' => -10, 'name' => 'Alice']; + expect(RuntimeTypeChecker::checkReturn($target, $invalid, $user))->toBeInstanceOf(ErrorMessage::class); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkReturn($target, $invalid, $user))->toBe($invalid); + }); + + test('checkSend returns send value or validates generator and handles disabled switch', function () { + expect(RuntimeTypeChecker::checkSend('nonExistentFunc', null))->toBeNull(); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkSend('nonExistentFunc', 100))->toBe(100); + }); + + test('checkYield returns yielded value and handles disabled switch', function () { + expect(RuntimeTypeChecker::checkYield('nonExistentFunc', 'key', 'value'))->toBe('value'); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkYield('nonExistentFunc', 'key', 'value'))->toBe('value'); + }); + + test('wrapCallable and wrapIterable respect disabled switch', function () { + $cb = fn() => 1; + expect(RuntimeTypeChecker::wrapCallable('nonExistent', 'arg', $cb))->toBe($cb); + + $iter = [1, 2, 3]; + expect(RuntimeTypeChecker::wrapIterable('nonExistent', 'arg', $iter))->toBe($iter); + + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::wrapCallable('nonExistent', 'arg', $cb))->toBe($cb); + expect(RuntimeTypeChecker::wrapIterable('nonExistent', 'arg', $iter))->toBe($iter); + }); + + test('prepareClone and cloneInstance clone generic bindings', function () { + $dog = new Dog(); + expect(RuntimeTypeChecker::prepareClone($dog))->toBe($dog); + expect(RuntimeTypeChecker::prepareClone(123))->toBe(123); + + $cloned = new Dog(); + expect(RuntimeTypeChecker::cloneInstance($cloned, $dog))->toBe($cloned); + expect(RuntimeTypeChecker::cloneInstance(123, 456))->toBe(123); + }); + + test('inferTypeFromValue and getRegistry helpers', function () { + $node = RuntimeTypeChecker::inferTypeFromValue(10); + expect((string) $node)->toBe('int'); + + expect(RuntimeTypeChecker::getRegistry())->toBeInstanceOf(TypeValidatorRegistry::class); + }); + + test('handles ignored callers across checkers and return methods', function () { + $dog = new Container(new Dog()); + $badNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Cat::class)]); + + $resNode = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode) ); - expect($valid)->toBeNull(); + expect($resNode)->toBeNull(); - $invalid = RuntimeTypeChecker::checkParamOut( - 'TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', - 'id', - -50 + $resVar = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'val', __FILE__) ); - expect($invalid)->toBeInstanceOf(ErrorMessage::class); + expect($resVar)->toBe(-5); + + $fixture = new ConfiguredProperty(); + $resProp = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkProperty(['bad'], $fixture, 'numbers', __FILE__) + ); + expect($resProp)->toBe(['bad']); + + $resParams = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkParams(UserService::class . '::find', ['id' => -1], new UserService()) + ); + expect($resParams)->toBeNull(); + + $resOut = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkParamOut('TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', 'id', -50) + ); + expect($resOut)->toBeNull(); + + RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkSelfOut(\TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login', new \TypePHP\Tests\TypeChecking\Generics\FixtureSession()) + ); + + $resRet = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkReturn(UserService::class . '::find', ['id' => -1, 'name' => 'Alice'], new UserService()) + ); + expect($resRet)->toBe(['id' => -1, 'name' => 'Alice']); + + $resSend = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkSend('sampleGeneratorFixture', -50) + ); + expect($resSend)->toBe(-50); + + $resYield = RuntimeCheckerIgnoredCaller::run( + fn() => RuntimeTypeChecker::checkYield('sampleGeneratorFixture', 123, -50) + ); + expect($resYield)->toBe(-50); + }); + + test('covers checkSelfOut effective function cache branch', function () { + SelfOutChecker::$noSelfOutContractCache['stdClass::runMethod'] = true; + unset(SelfOutChecker::$noSelfOutContractCache['FakeParent::runMethod']); + + RuntimeTypeChecker::checkSelfOut('FakeParent::runMethod', new stdClass()); + + expect(SelfOutChecker::$noSelfOutContractCache['FakeParent::runMethod'])->toBeTrue(); + }); + + test('handles vendor boundary bypasses across methods', function () { + $tempDir = sys_get_temp_dir() . '/typephp_rt_vendor_' . uniqid(); + $vendorDir = $tempDir . '/vendor/acme/caller'; + mkdir($vendorDir, 0777, true); + $vendorFile = $vendorDir . '/VendorCaller.php'; + + file_put_contents( + $vendorFile, + <<<'PHP' + RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode, $fnName) + ); + expect($resNode)->toBeNull(); + + $resVar = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'v', __FILE__, $fnName) + ); + expect($resVar)->toBe(-5); + + $resProp = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkProperty(['bad'], 'Acme\VendorTest\VendorCaller', 'prop', __FILE__) + ); + expect($resProp)->toBe(['bad']); + + $resParams = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkParams($fnName, ['a' => 1]) + ); + expect($resParams)->toBeNull(); + + $resOut = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkParamOut($fnName, 'param', 1) + ); + expect($resOut)->toBeNull(); + + \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkSelfOut($fnName, new stdClass()) + ); + + $resRet = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkReturn($fnName, 'any') + ); + expect($resRet)->toBe('any'); + + $resSend = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkSend($fnName, 'val') + ); + expect($resSend)->toBe('val'); + + $resYield = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::checkYield($fnName, 'k', 'v') + ); + expect($resYield)->toBe('v'); + + $cb = fn() => 1; + $resCb = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::wrapCallable($fnName, 'cb', $cb) + ); + expect($resCb)->toBe($cb); + + $iter = [1]; + $resIter = \Acme\VendorTest\VendorCaller::call( + fn() => RuntimeTypeChecker::wrapIterable($fnName, 'iter', $iter) + ); + expect($resIter)->toBe($iter); + } finally { + @unlink($vendorFile); + @rmdir($vendorDir); + @rmdir($tempDir . '/vendor/acme'); + @rmdir($tempDir . '/vendor'); + @rmdir($tempDir); + } + }); + + test('covers setupScope and checkReturn unconstrained contracts and caching branches', function () { + ParamChecker::$noParamContractCache['testNoParamCached'] = true; + RuntimeTypeChecker::$hasMethodTemplatesCache['testNoParamCached'] = false; + expect(RuntimeTypeChecker::setupScope('testNoParamCached', []))->toBeNull(); + + expect(RuntimeTypeChecker::setupScope('runtimeUnconstrainedParams', ['a' => 1]))->toBeNull(); + + SelfOutChecker::$noSelfOutContractCache['stdClass::none'] = true; + RuntimeTypeChecker::checkSelfOut('stdClass::none', new stdClass()); + + expect(RuntimeTypeChecker::checkReturn('runtimeUnconstrainedReturn', 100))->toBe(100); }); }); diff --git a/tests/Internal/Validator/GenericValidatorTest.php b/tests/Internal/Validator/GenericValidatorTest.php index f167835..e06a78c 100644 --- a/tests/Internal/Validator/GenericValidatorTest.php +++ b/tests/Internal/Validator/GenericValidatorTest.php @@ -16,16 +16,28 @@ use TypePHP\Internal\Util\Config; use TypePHP\Internal\Validator\GenericValidator; use TypePHP\Internal\Validator\TypeValidatorRegistry; +use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Enums\Suit; +use TypePHP\Tests\Fixtures\Enums\TransactionStatus; use TypePHP\Tests\Fixtures\Generics\Producer; use TypePHP\Tests\Fixtures\Types\BitmaskFlags; use TypePHP\Tests\Fixtures\Types\DatabaseDriverMap; +if (! \defined('TYPEPHP_TEST_GLOBAL_MAP')) { + \define('TYPEPHP_TEST_GLOBAL_MAP', ['first' => 10, 'second' => 20]); +} + +if (! \defined('TYPEPHP_TEST_GLOBAL_SCALAR')) { + \define('TYPEPHP_TEST_GLOBAL_SCALAR', 'scalar_val'); +} + class GenericConstFlagsFixture { public const FLAG_A = 1; public const FLAG_B = 2; public const FLAGS_ARRAY = [1, 2, 4]; + public const SCALAR_CONST = 'not_an_array'; } describe('GenericValidator Unit Tests', function () { @@ -38,87 +50,147 @@ class GenericConstFlagsFixture Config::reset(); }); - test('validates integer ranges with min and max bounds', function () { - $range = new GenericTypeNode(new IdentifierTypeNode('int'), [ - new ConstExprIntegerNode('1'), - new ConstExprIntegerNode('10'), + test('validates key-of on array shape with constant fetch keys', function () { + $shape = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstFetchNode('self', 'SOME_KEY'), false, new IdentifierTypeNode('string')), + ]), ]); - expect($this->validator->validate(5, $range, 'val', $this->registry))->toBeNull() - ->and($this->validator->validate(0, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate(15, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate('not_int', $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ; + expect($this->validator->validate('self::SOME_KEY', $shape, 'key', $this->registry))->toBeNull(); + expect($this->validator->validate('other', $shape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class); + }); - $minRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ - new IdentifierTypeNode('min'), - new ConstExprIntegerNode('100'), + test('validates class-string with non-identifier target node', function () { + $node = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ + new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]), ]); - expect($this->validator->validate(-500, $minRange, 'val', $this->registry))->toBeNull() - ->and($this->validator->validate(105, $minRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + + expect($this->validator->validate(stdClass::class, $node, 'cls', $this->registry))->toBeNull(); + }); + + test('validates single argument array with scalars in hybrid mode', function () { + Config::set(['array_validation' => 'hybrid']); + + $large = range(1, 150); + $node = new GenericTypeNode(new IdentifierTypeNode('array'), [new IdentifierTypeNode('int')]); + + expect($this->validator->validate($large, $node, 'arr', $this->registry))->toBeNull(); + }); + + test('validates two argument array with complex object generic values in hybrid mode', function () { + Config::set(['array_validation' => 'hybrid']); + + $large = []; + for ($i = 0; $i < 150; $i++) { + $large["key_{$i}"] = new Producer(new Dog()); + } + + $node = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('string'), + new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]), + ]); + + expect($this->validator->validate($large, $node, 'arr', $this->registry))->toBeNull(); + }); + + test('covers resolveConstantValue for global constants in key-of and value-of', function () { + $globalKeyOf = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + new ConstTypeNode(new ConstFetchNode('', 'TYPEPHP_TEST_GLOBAL_MAP')), + ]); + expect($this->validator->validate('first', $globalKeyOf, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate('second', $globalKeyOf, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate('third', $globalKeyOf, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class) ; - $maxRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ - new ConstExprIntegerNode('0'), - new IdentifierTypeNode('max'), + $globalValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ + new ConstTypeNode(new ConstFetchNode('', 'TYPEPHP_TEST_GLOBAL_MAP')), ]); - expect($this->validator->validate(500, $maxRange, 'val', $this->registry))->toBeNull() - ->and($this->validator->validate(-1, $maxRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + expect($this->validator->validate(10, $globalValueOf, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate(20, $globalValueOf, 'arg', $this->registry))->toBeNull() + ->and($this->validator->validate(999, $globalValueOf, 'arg', $this->registry))->toBeInstanceOf(ErrorMessage::class) ; }); - test('validates class-string with union and intersection bounds', function () { - $bareClassString = new GenericTypeNode(new IdentifierTypeNode('class-string'), []); - expect($this->validator->validate(stdClass::class, $bareClassString, 'cls', $this->registry))->toBeNull(); - - $unionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ - new UnionTypeNode([new IdentifierTypeNode(DateTime::class), new IdentifierTypeNode(ArrayObject::class)]), + test('validates key-of on array shapes with quoted string keys and explicit integer keys', function () { + $quotedShape = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprStringNode('user_id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new ConstExprStringNode('user_name', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), + ]), ]); - expect($this->validator->validate(DateTime::class, $unionBound, 'cls', $this->registry))->toBeNull() - ->and($this->validator->validate(ArrayObject::class, $unionBound, 'cls', $this->registry))->toBeNull() - ->and($this->validator->validate(stdClass::class, $unionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + expect($this->validator->validate('user_id', $quotedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate('user_name', $quotedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate('missing_key', $quotedShape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) ; - $intersectionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ - new IntersectionTypeNode([new IdentifierTypeNode('Countable'), new IdentifierTypeNode('ArrayAccess')]), + $intKeyShape = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new IdentifierTypeNode('string')), + new ArrayShapeItemNode(new ConstExprIntegerNode('10'), false, new IdentifierTypeNode('int')), + ]), ]); - expect($this->validator->validate(ArrayObject::class, $intersectionBound, 'cls', $this->registry))->toBeNull() - ->and($this->validator->validate(stdClass::class, $intersectionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + expect($this->validator->validate(0, $intKeyShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate(10, $intKeyShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate(1, $intKeyShape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) ; }); - test('validates key-of on nested value-of and array shapes with multiple key styles', function () { + test('validates key-of on nested value-of with ConstFetchNode and keyless tuple items', function () { $nestedShape = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ new GenericTypeNode(new IdentifierTypeNode('value-of'), [ ArrayShapeNode::createSealed([ new ArrayShapeItemNode(new IdentifierTypeNode('config'), false, ArrayShapeNode::createSealed([ new ArrayShapeItemNode(new ConstExprStringNode('host', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('string')), new ArrayShapeItemNode(new ConstExprIntegerNode('0'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new IdentifierTypeNode('port'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(new ConstFetchNode(DatabaseDriverMap::class, 'PUBLIC_MAP'), false, new IdentifierTypeNode('int')), + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('string')), ])), ]), ]), ]); - expect($this->validator->validate('host', $nestedShape, 'key', $this->registry))->toBeNull(); - expect($this->validator->validate(0, $nestedShape, 'key', $this->registry))->toBeNull(); - expect($this->validator->validate('missing', $nestedShape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate('host', $nestedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate(0, $nestedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate('port', $nestedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate(DatabaseDriverMap::class . '::PUBLIC_MAP', $nestedShape, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate('unmatched_key', $nestedShape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); - $shapeWithAutoIndex = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ - ArrayShapeNode::createSealed([ - new ArrayShapeItemNode(null, false, new IdentifierTypeNode('string')), - new ArrayShapeItemNode(new ConstFetchNode('self', 'KEY'), false, new IdentifierTypeNode('string')), - ]), + test('validates key-of on Enums and non-array constant fallbacks', function () { + $enumKeyOf = new GenericTypeNode(new IdentifierTypeNode('key-of'), [new IdentifierTypeNode(Suit::class)]); + expect($this->validator->validate('Hearts', $enumKeyOf, 'key', $this->registry))->toBeNull() + ->and($this->validator->validate('invalid_case', $enumKeyOf, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $constKeyOf = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + new ConstTypeNode(new ConstFetchNode(DatabaseDriverMap::class, 'PUBLIC_MAP')), + ]); + expect($this->validator->validate(true, $constKeyOf, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(1.5, $constKeyOf, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $scalarConstKeyOf = new GenericTypeNode(new IdentifierTypeNode('key-of'), [ + new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'SCALAR_CONST')), ]); - expect($this->validator->validate(0, $shapeWithAutoIndex, 'key', $this->registry))->toBeNull(); - expect($this->validator->validate('self::KEY', $shapeWithAutoIndex, 'key', $this->registry))->toBeNull(); + expect($this->validator->validate('anything', $scalarConstKeyOf, 'key', $this->registry))->toBeNull(); }); - test('validates value-of on constant arrays and array shapes', function () { - $constValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ - new ConstTypeNode(new ConstFetchNode(DatabaseDriverMap::class, 'PUBLIC_MAP')), + test('validates value-of on BackedEnums, UnitEnums, and non-array constants', function () { + $backedEnum = new GenericTypeNode(new IdentifierTypeNode('value-of'), [new IdentifierTypeNode(TransactionStatus::class)]); + expect($this->validator->validate(1, $backedEnum, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(99, $backedEnum, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $unitEnum = new GenericTypeNode(new IdentifierTypeNode('value-of'), [new IdentifierTypeNode(Suit::class)]); + expect($this->validator->validate('Hearts', $unitEnum, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $scalarConstValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ + new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'SCALAR_CONST')), ]); - expect($this->validator->validate(1, $constValueOf, 'val', $this->registry))->toBeNull(); - expect($this->validator->validate(99, $constValueOf, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate('anything', $scalarConstValueOf, 'val', $this->registry))->toBeNull(); $shapeValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [ ArrayShapeNode::createSealed([ @@ -126,54 +198,181 @@ class GenericConstFlagsFixture new ArrayShapeItemNode(new IdentifierTypeNode('role'), false, new IdentifierTypeNode('string')), ]), ]); - expect($this->validator->validate(10, $shapeValueOf, 'val', $this->registry))->toBeNull(); - expect($this->validator->validate('admin', $shapeValueOf, 'val', $this->registry))->toBeNull(); - expect($this->validator->validate(false, $shapeValueOf, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(10, $shapeValueOf, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate('admin', $shapeValueOf, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(false, $shapeValueOf, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('validates int-mask and int-mask-of with constant fetches and array of flags', function () { - $intMaskConst = new GenericTypeNode(new IdentifierTypeNode('int-mask'), [ - new ConstTypeNode(new ConstFetchNode(BitmaskFlags::class, 'FLAG_READ')), - new ConstTypeNode(new ConstFetchNode(BitmaskFlags::class, 'FLAG_WRITE')), + test('validates int-mask and int-mask-of with non-integers, wildcards, and literals', function () { + $intMask = new GenericTypeNode(new IdentifierTypeNode('int-mask'), [ + new ConstTypeNode(new ConstExprIntegerNode('1')), + new ConstTypeNode(new ConstExprIntegerNode('2')), ]); + expect($this->validator->validate('not_an_int', $intMask, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(3, $intMask, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(4, $intMask, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; - expect($this->validator->validate(1, $intMaskConst, 'mask', $this->registry))->toBeNull(); - expect($this->validator->validate(3, $intMaskConst, 'mask', $this->registry))->toBeNull(); - expect($this->validator->validate(8, $intMaskConst, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); - expect($this->validator->validate('not_int', $intMaskConst, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + $intMaskOf = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [ + new ConstTypeNode(new ConstFetchNode(BitmaskFlags::class, 'FLAG_*')), + ]); + expect($this->validator->validate('not_an_int', $intMaskOf, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(12.34, $intMaskOf, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + expect($this->validator->validate(1, $intMaskOf, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(3, $intMaskOf, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(7, $intMaskOf, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(16, $intMaskOf, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; $intMaskOfArray = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [ new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'FLAGS_ARRAY')), ]); - expect($this->validator->validate(7, $intMaskOfArray, 'mask', $this->registry))->toBeNull(); - expect($this->validator->validate(16, $intMaskOfArray, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(7, $intMaskOfArray, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(16, $intMaskOfArray, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; $intMaskOfSingle = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [ new ConstTypeNode(new ConstFetchNode(GenericConstFlagsFixture::class, 'FLAG_A')), ]); - expect($this->validator->validate(1, $intMaskOfSingle, 'mask', $this->registry))->toBeNull(); - expect($this->validator->validate(4, $intMaskOfSingle, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate(1, $intMaskOfSingle, 'mask', $this->registry))->toBeNull() + ->and($this->validator->validate(4, $intMaskOfSingle, 'mask', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $emptyMaskOf = new GenericTypeNode(new IdentifierTypeNode('int-mask-of'), [new IdentifierTypeNode('int')]); + expect($this->validator->validate(1, $emptyMaskOf, 'mask', $this->registry))->toBeNull(); + }); + + test('validates integer ranges with min and max bounds and non-int inputs', function () { + $range = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new ConstExprIntegerNode('1'), + new ConstExprIntegerNode('10'), + ]); + + expect($this->validator->validate(5, $range, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(0, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(15, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate('not_int', $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(3.14, $range, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $minRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new IdentifierTypeNode('min'), + new ConstExprIntegerNode('100'), + ]); + expect($this->validator->validate(-500, $minRange, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(105, $minRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $maxRange = new GenericTypeNode(new IdentifierTypeNode('int'), [ + new ConstExprIntegerNode('0'), + new IdentifierTypeNode('max'), + ]); + expect($this->validator->validate(500, $maxRange, 'val', $this->registry))->toBeNull() + ->and($this->validator->validate(-1, $maxRange, 'val', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); + + test('validates class-string edge cases including non-string, object/mixed bounds, and intersection failures', function () { + $bareClassString = new GenericTypeNode(new IdentifierTypeNode('class-string'), []); + expect($this->validator->validate(stdClass::class, $bareClassString, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(12345, $bareClassString, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate('Invalid Class Name!', $bareClassString, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $objectBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [new IdentifierTypeNode('object')]); + expect($this->validator->validate(stdClass::class, $objectBound, 'cls', $this->registry))->toBeNull(); + + $mixedBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [new IdentifierTypeNode('mixed')]); + expect($this->validator->validate(stdClass::class, $mixedBound, 'cls', $this->registry))->toBeNull(); + + $singleBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [new IdentifierTypeNode(DateTime::class)]); + expect($this->validator->validate(DateTime::class, $singleBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $singleBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $unionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ + new UnionTypeNode([new IdentifierTypeNode(DateTime::class), new IdentifierTypeNode(ArrayObject::class)]), + ]); + expect($this->validator->validate(DateTime::class, $unionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(ArrayObject::class, $unionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $unionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + $intersectionBound = new GenericTypeNode(new IdentifierTypeNode('class-string'), [ + new IntersectionTypeNode([new IdentifierTypeNode('Countable'), new IdentifierTypeNode('ArrayAccess')]), + ]); + expect($this->validator->validate(ArrayObject::class, $intersectionBound, 'cls', $this->registry))->toBeNull() + ->and($this->validator->validate(stdClass::class, $intersectionBound, 'cls', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; }); - test('validates lists and non-empty-lists with exhaustive and hybrid sampling', function () { + test('validates lists and non-empty-lists with mixed shortcuts, complex objects, and hybrid sampling', function () { + $intList = new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('int')]); + expect($this->validator->validate('not_array', $intList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate([], $intList, 'list', $this->registry))->toBeNull() + ; + $nonEmptyList = new GenericTypeNode(new IdentifierTypeNode('non-empty-list'), [new IdentifierTypeNode('int')]); - expect($this->validator->validate([], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); - expect($this->validator->validate(['key' => 1], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + expect($this->validator->validate([], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(['key' => 1], $nonEmptyList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $mixedList = new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('mixed')]); + expect($this->validator->validate([1, 'string', false], $mixedList, 'list', $this->registry))->toBeNull(); + + $complexList = new GenericTypeNode(new IdentifierTypeNode('list'), [ + new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]), + ]); + expect($this->validator->validate([new Producer(new Dog())], $complexList, 'list', $this->registry))->toBeNull() + ->and($this->validator->validate([new Producer(new Car())], $complexList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; Config::set(['array_validation' => 'hybrid']); - $largeList = range(1, 150); - $posIntList = new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('positive-int')]); - expect($this->validator->validate($largeList, $posIntList, 'list', $this->registry))->toBeNull(); + $largeList = []; + for ($i = 0; $i < 150; $i++) { + $largeList[] = new Producer(new Dog()); + } + expect($this->validator->validate($largeList, $complexList, 'list', $this->registry))->toBeNull(); $badLargeList = $largeList; - $badLargeList[0] = -10; - expect($this->validator->validate($badLargeList, $posIntList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + $badLargeList[0] = new Producer(new Car()); + expect($this->validator->validate($badLargeList, $complexList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $largeIntList = range(1, 150); + $posIntList = new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('positive-int')]); + expect($this->validator->validate($largeIntList, $posIntList, 'list', $this->registry))->toBeNull(); + + $badLargeIntList = $largeIntList; + $badLargeIntList[0] = -10; + expect($this->validator->validate($badLargeIntList, $posIntList, 'list', $this->registry))->toBeInstanceOf(ErrorMessage::class); }); - test('validates generic arrays and handles unconstrained key and value shortcuts', function () { + test('validates generic arrays with Traversable bypass, shortcuts, and hybrid sampling', function () { + $genericArray = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int'), + ]); + + expect($this->validator->validate('not_arr', $genericArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + expect($this->validator->validate(new ArrayIterator(['a' => 1]), $genericArray, 'arr', $this->registry))->toBeNull(); + + expect($this->validator->validate([], $genericArray, 'arr', $this->registry))->toBeNull(); + $nonEmptyArray = new GenericTypeNode(new IdentifierTypeNode('non-empty-array'), [new IdentifierTypeNode('int')]); expect($this->validator->validate([], $nonEmptyArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); - expect($this->validator->validate('not_arr', $nonEmptyArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $singleMixedArray = new GenericTypeNode(new IdentifierTypeNode('array'), [new IdentifierTypeNode('mixed')]); + expect($this->validator->validate(['a' => 1, 'b' => 'text'], $singleMixedArray, 'arr', $this->registry))->toBeNull(); + + $singleComplexArray = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]), + ]); + expect($this->validator->validate([new Producer(new Dog())], $singleComplexArray, 'arr', $this->registry))->toBeNull() + ->and($this->validator->validate([new Producer(new Car())], $singleComplexArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; $unconstrainedMap = new GenericTypeNode(new IdentifierTypeNode('array'), [ new IdentifierTypeNode('array-key'), @@ -181,11 +380,33 @@ class GenericConstFlagsFixture ]); expect($this->validator->validate(['a' => 'anything', 1 => 42], $unconstrainedMap, 'map', $this->registry))->toBeNull(); - $singleGenericArray = new GenericTypeNode(new IdentifierTypeNode('array'), [new IdentifierTypeNode('positive-int')]); - expect($this->validator->validate([1, 2, 3], $singleGenericArray, 'arr', $this->registry))->toBeNull(); - expect($this->validator->validate([1, -2], $singleGenericArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + $complexMap = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('string'), + new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]), + ]); + expect($this->validator->validate(['item' => new Producer(new Dog())], $complexMap, 'map', $this->registry))->toBeNull() + ->and($this->validator->validate(['item' => new Producer(new Car())], $complexMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + + $strictMap = new GenericTypeNode(new IdentifierTypeNode('array'), [ + new IdentifierTypeNode('int'), + new IdentifierTypeNode('positive-int'), + ]); + expect($this->validator->validate(['string_key' => 10], $strictMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate([0 => -5], $strictMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; Config::set(['array_validation' => 'hybrid']); + $largeSingleArray = []; + for ($i = 0; $i < 150; $i++) { + $largeSingleArray[] = new Producer(new Dog()); + } + expect($this->validator->validate($largeSingleArray, $singleComplexArray, 'arr', $this->registry))->toBeNull(); + + $badLargeSingle = $largeSingleArray; + $badLargeSingle[0] = new Producer(new Car()); + expect($this->validator->validate($badLargeSingle, $singleComplexArray, 'arr', $this->registry))->toBeInstanceOf(ErrorMessage::class); + $largeMap = []; for ($i = 0; $i < 150; $i++) { $largeMap["k_{$i}"] = $i + 1; @@ -196,18 +417,23 @@ class GenericConstFlagsFixture ]); expect($this->validator->validate($largeMap, $genericMap, 'map', $this->registry))->toBeNull(); - $badLargeMap = $largeMap; - $badLargeMap['k_0'] = -50; - expect($this->validator->validate($badLargeMap, $genericMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class); + $badKeyMap = $largeMap; + $badKeyMap = [0 => 10, ...$largeMap]; + expect($this->validator->validate($badKeyMap, $genericMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class); + + $badValueMap = $largeMap; + $badValueMap['k_0'] = -50; + expect($this->validator->validate($badValueMap, $genericMap, 'map', $this->registry))->toBeInstanceOf(ErrorMessage::class); }); - test('validates object generics and handles invalid syntax gracefully', function () { + test('validates object generics and handles non-object and wrong class inputs', function () { $invalidSyntax = new GenericTypeNode(new IdentifierTypeNode('invalid-class!'), [new IdentifierTypeNode('int')]); expect($this->validator->validate(new stdClass(), $invalidSyntax, 'obj', $this->registry))->toBeNull(); $producerDog = new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)]); - expect($this->validator->validate('not_an_object', $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class); - expect($this->validator->validate(new stdClass(), $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class); - expect($this->validator->validate(new Producer(new Dog()), $producerDog, 'p', $this->registry))->toBeNull(); + expect($this->validator->validate('not_an_object', $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(new stdClass(), $producerDog, 'p', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(new Producer(new Dog()), $producerDog, 'p', $this->registry))->toBeNull() + ; }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Validator/ObjectShapeValidatorTest.php b/tests/Internal/Validator/ObjectShapeValidatorTest.php new file mode 100644 index 0000000..7100c4d --- /dev/null +++ b/tests/Internal/Validator/ObjectShapeValidatorTest.php @@ -0,0 +1,143 @@ + 'valid_string', + 'badMagicProp' => 12345, + ]; + + public function __isset(string $name): bool + { + return isset($this->data[$name]); + } + + public function __get(string $name): mixed + { + return $this->data[$name] ?? null; + } +} + +describe('ObjectShapeValidator Unit Tests', function () { + beforeEach(function () { + $this->registry = new TypeValidatorRegistry(); + $this->validator = new ObjectShapeValidator(); + }); + + test('rejects non-object inputs', function () { + $shape = new ObjectShapeNode([]); + + $err = $this->validator->validate('not_an_object', $shape, 'target', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type object') + ; + }); + + test('validates stdClass instances', function () { + $shape = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + new ObjectShapeItemNode(new IdentifierTypeNode('role'), true, new IdentifierTypeNode('string')), + ]); + + $valid = (object) ['id' => 10, 'role' => 'admin']; + expect($this->validator->validate($valid, $shape, 'target', $this->registry))->toBeNull(); + + $validOptionalOmitted = (object) ['id' => 10]; + expect($this->validator->validate($validOptionalOmitted, $shape, 'target', $this->registry))->toBeNull(); + + $missingRequired = (object) ['role' => 'admin']; + $errMissing = $this->validator->validate($missingRequired, $shape, 'target', $this->registry); + expect($errMissing)->toBeInstanceOf(ErrorMessage::class) + ->and($errMissing->getMessage())->toContain("missing required property 'id'") + ; + + $invalidValue = (object) ['id' => -1]; + $errInvalid = $this->validator->validate($invalidValue, $shape, 'target', $this->registry); + expect($errInvalid)->toBeInstanceOf(ErrorMessage::class) + ->and($errInvalid->getMessage())->toContain('target->id') + ; + }); + + test('validates custom class instances with required and optional missing properties', function () { + $user = new ObjectShapeTestUser(); + + $shapeValid = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + new ObjectShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('string')), + new ObjectShapeItemNode(new IdentifierTypeNode('missingOptional'), true, new IdentifierTypeNode('string')), + ]); + expect($this->validator->validate($user, $shapeValid, 'target', $this->registry))->toBeNull(); + + $shapeMissingRequired = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('missingRequired'), false, new IdentifierTypeNode('string')), + ]); + $errMissing = $this->validator->validate($user, $shapeMissingRequired, 'target', $this->registry); + expect($errMissing)->toBeInstanceOf(ErrorMessage::class) + ->and($errMissing->getMessage())->toContain("missing required property 'missingRequired'") + ; + + $shapeInvalid = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('negative-int')), + ]); + $errInvalid = $this->validator->validate($user, $shapeInvalid, 'target', $this->registry); + expect($errInvalid)->toBeInstanceOf(ErrorMessage::class) + ->and($errInvalid->getMessage())->toContain('target->id') + ; + }); + + test('validates uninitialized properties on custom class instances', function () { + $obj = new ObjectShapeUninitClass(); + + $shapeRequiredUninit = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('int')), + ]); + $errRequired = $this->validator->validate($obj, $shapeRequiredUninit, 'target', $this->registry); + expect($errRequired)->toBeInstanceOf(ErrorMessage::class) + ->and($errRequired->getMessage())->toContain("property 'id' is uninitialized") + ; + + $shapeOptionalUninit = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('uninitOptional'), true, new IdentifierTypeNode('string')), + ]); + expect($this->validator->validate($obj, $shapeOptionalUninit, 'target', $this->registry))->toBeNull(); + }); + + test('validates properties resolved through magic getters on custom class instances', function () { + $magicObj = new ObjectShapeMagicClass(); + + $shapeValid = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('magicProp'), false, new IdentifierTypeNode('string')), + ]); + expect($this->validator->validate($magicObj, $shapeValid, 'target', $this->registry))->toBeNull(); + + $shapeInvalid = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('badMagicProp'), false, new IdentifierTypeNode('string')), + ]); + $errInvalid = $this->validator->validate($magicObj, $shapeInvalid, 'target', $this->registry); + expect($errInvalid)->toBeInstanceOf(ErrorMessage::class) + ->and($errInvalid->getMessage())->toContain('target->badMagicProp') + ; + }); +}); \ No newline at end of file diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index df6e26a..848f099 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -4,6 +4,8 @@ namespace TypePHP\Tests\Unit; +use stdClass; +use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Tests\Fixtures\Domain\Cat; use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Generics\DogRepository; @@ -34,11 +36,48 @@ class MultiTemplateDictionary public array $map = []; } +/** + * @template T + * @template U + */ +class MultiTemplatePair +{ +} + +/** + * @template A + * @template B + */ +class MultiTemplateNoDefaults +{ +} + +/** + * @template-covariant T + * @template-contravariant U + */ +class MultiVariancePair +{ +} + +/** + * @template-covariant A + * @template-contravariant B + */ +class MultiVarianceNoDefaults +{ +} + describe('TypePHP Public Facade Unit Tests', function () { afterEach(function () { TypePHP::resetConfig(); }); + test('boots stream wrapper cleanly', function () { + TypePHP::boot(); + expect(TypePHP::getConfig())->toBeArray(); + }); + test('gets resolved configuration using getConfig', function () { $config = TypePHP::getConfig(); @@ -69,7 +108,6 @@ class MultiTemplateDictionary }); test('inspects runtime reified generic types across single, custom, multi, and inherited template instances', function () { - // Standard Single Template (GenericCollection vs GenericCollection) /** @var GenericCollection $dogCollection */ $dogCollection = new GenericCollection(); @@ -81,7 +119,6 @@ class MultiTemplateDictionary ->and(TypePHP::getGenericTypes($dogCollection))->toBe(['T' => Dog::class]) ; - // Custom Named Single Template (@template ItemType) /** @var CustomTemplateNameBox $box */ $box = new CustomTemplateNameBox(); @@ -90,7 +127,6 @@ class MultiTemplateDictionary ->and(TypePHP::getGenericTypes($box))->toBe(['ItemType' => Dog::class]) ; - // Multiple Templates (@template K, @template V) /** @var MultiTemplateDictionary $dict */ $dict = new MultiTemplateDictionary(); @@ -99,33 +135,49 @@ class MultiTemplateDictionary ->and(TypePHP::getGenericTypes($dict))->toBe(['K' => 'string', 'V' => Dog::class]) ; - // Inherited Generic Class (@extends Repository) $dogRepo = new DogRepository(); expect(TypePHP::getGenericType($dogRepo))->toBe(Dog::class) ->and(TypePHP::getGenericTypes($dogRepo))->toBe(['T' => Dog::class]) ; - // Unannotated Instance before and after first-use type inference $mystery = new GenericCollection(); expect(TypePHP::getGenericType($mystery))->toBeNull() ->and(TypePHP::getGenericTypes($mystery))->toBeEmpty() ; - $mystery->add(new Dog()); // First method call infers T = Dog! + $mystery->add(new Dog()); expect(TypePHP::getGenericType($mystery))->toBe(Dog::class) ->and(TypePHP::getGenericTypes($mystery))->toBe(['T' => Dog::class]) ; + + $pair = new MultiTemplatePair(); + TemplateManager::bindInstance($pair, MultiTemplatePair::class . '<' . Dog::class . ', ' . Cat::class . '>'); + expect(TypePHP::getGenericType($pair))->toBe(Dog::class); + + $noDefault = new MultiTemplateNoDefaults(); + TemplateManager::bindInstance($noDefault, MultiTemplateNoDefaults::class . '<' . Dog::class . ', ' . Cat::class . '>'); + expect(TypePHP::getGenericType($noDefault))->toBeNull(); }); test('inspects declared template variances on object instances', function () { + expect(TypePHP::getGenericVariance(new stdClass()))->toBe('invariant'); + /** @var Producer $producer */ $producer = new Producer(new Dog()); expect(TypePHP::getGenericVariance($producer))->toBe('covariant') + ->and(TypePHP::getGenericVariance($producer, 'T'))->toBe('covariant') ->and(TypePHP::getGenericVariances($producer))->toBe(['T' => 'covariant']) ; + + $varPair = new MultiVariancePair(); + expect(TypePHP::getGenericVariance($varPair, 'U'))->toBe('contravariant'); + expect(TypePHP::getGenericVariance($varPair))->toBe('covariant'); + + $noDefVar = new MultiVarianceNoDefaults(); + expect(TypePHP::getGenericVariance($noDefVar))->toBe('invariant'); }); -}); +}); \ No newline at end of file From 00343b78f020c69203c460ea7c2ccc09319fc4c7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:10:02 +0800 Subject: [PATCH 13/17] fix code styling --- src/Internal/Diagnostic/TypeFormatter.php | 2 +- .../Validator/ArrayShapeValidator.php | 2 +- src/Internal/Validator/ConstValidator.php | 2 +- src/Internal/Validator/GenericValidator.php | 4 +- .../Internal/Diagnostic/ErrorFactoryTest.php | 2 +- .../Internal/Diagnostic/TypeFormatterTest.php | 2 +- tests/Internal/RuntimeTypeCheckerTest.php | 85 ++++++++++--------- tests/Internal/Util/StubManagerTest.php | 29 +++---- .../Validator/ArrayShapeValidatorTest.php | 2 +- .../Internal/Validator/ArrayValidatorTest.php | 2 +- .../Internal/Validator/ConstValidatorTest.php | 3 +- .../Validator/IdentifierValidatorTest.php | 5 +- .../Validator/IntersectionValidatorTest.php | 1 + .../Validator/ObjectShapeValidatorTest.php | 4 +- .../Internal/Wrapper/CallableWrapperTest.php | 2 +- .../Internal/Wrapper/IterableWrapperTest.php | 8 +- tests/Internal/Wrapper/IteratorProxyTest.php | 2 +- tests/Unit/TypePHPTest.php | 4 +- 18 files changed, 83 insertions(+), 78 deletions(-) diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index b6a0b0f..cabd02b 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -77,4 +77,4 @@ public static function formatGivenValue(mixed $value, bool $isSensitive = false) return get_debug_type($value); } -} \ No newline at end of file +} diff --git a/src/Internal/Validator/ArrayShapeValidator.php b/src/Internal/Validator/ArrayShapeValidator.php index 7528227..58cbf4b 100644 --- a/src/Internal/Validator/ArrayShapeValidator.php +++ b/src/Internal/Validator/ArrayShapeValidator.php @@ -109,4 +109,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} \ No newline at end of file +} diff --git a/src/Internal/Validator/ConstValidator.php b/src/Internal/Validator/ConstValidator.php index cebc834..cd1f0b1 100644 --- a/src/Internal/Validator/ConstValidator.php +++ b/src/Internal/Validator/ConstValidator.php @@ -116,4 +116,4 @@ private static function resolveWildcardConstantValues(string $className, string return self::$wildcardConstantCache[$cacheKey] = $values; } -} \ No newline at end of file +} diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 9d5002b..ade2741 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -152,7 +152,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont $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()); + self::$enumKeyCache[$enumClass] = array_map(fn ($case) => $case->name, $enumClass::cases()); } if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { @@ -236,7 +236,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co 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()); + self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases()); } if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { diff --git a/tests/Internal/Diagnostic/ErrorFactoryTest.php b/tests/Internal/Diagnostic/ErrorFactoryTest.php index 68c4368..7999cd7 100644 --- a/tests/Internal/Diagnostic/ErrorFactoryTest.php +++ b/tests/Internal/Diagnostic/ErrorFactoryTest.php @@ -68,4 +68,4 @@ $err = ErrorFactory::createError($rawMsg); expect($err->getMessage())->toBe("getOrderDiscount(): Return value must be of type positive-int, string 'discount given to customer' returned"); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Diagnostic/TypeFormatterTest.php b/tests/Internal/Diagnostic/TypeFormatterTest.php index ecbfe0d..3473360 100644 --- a/tests/Internal/Diagnostic/TypeFormatterTest.php +++ b/tests/Internal/Diagnostic/TypeFormatterTest.php @@ -61,4 +61,4 @@ test('formats object instances using get_debug_type fallback', function () { expect(TypeFormatter::formatGivenValue(new stdClass()))->toBe('stdClass'); -}); \ No newline at end of file +}); diff --git a/tests/Internal/RuntimeTypeCheckerTest.php b/tests/Internal/RuntimeTypeCheckerTest.php index 46ab4ec..2074f63 100644 --- a/tests/Internal/RuntimeTypeCheckerTest.php +++ b/tests/Internal/RuntimeTypeCheckerTest.php @@ -46,8 +46,6 @@ public function execute(int $id): int use TypePHP\Internal\Checker\ParamChecker; use TypePHP\Internal\Checker\SelfOutChecker; -use TypePHP\Internal\Docblock\DocblockParser; -use TypePHP\Internal\Resolver\CallerBoundaryResolver; class RuntimeCheckerIgnoredCaller { @@ -63,7 +61,9 @@ public static function run(callable $fn): mixed /** * @param mixed $a */ -function runtimeUnconstrainedParams(mixed $a): void {} +function runtimeUnconstrainedParams(mixed $a): void +{ +} /** * @return mixed @@ -104,14 +104,14 @@ function runtimeUnconstrainedReturn(): mixed test('withPendingGeneric pushes and pops instantiation context', function () { $instantiated = RuntimeTypeChecker::withPendingGeneric( Container::class . '<' . Dog::class . '>', - fn() => new Container(new Dog()), + fn () => new Container(new Dog()), __FILE__ ); expect($instantiated)->toBeInstanceOf(Container::class); Config::set(['enabled' => false]); - $bypass = RuntimeTypeChecker::withPendingGeneric('any', fn() => 123); + $bypass = RuntimeTypeChecker::withPendingGeneric('any', fn () => 123); expect($bypass)->toBe(123); }); @@ -123,8 +123,9 @@ function runtimeUnconstrainedReturn(): mixed $cached = RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'count', 10, __FILE__, 1); expect($cached)->toBe(10); - expect(fn() => RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'badCount', -5, __FILE__, 1)) - ->toThrow(TypeError::class, 'positive-int'); + expect(fn () => RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'badCount', -5, __FILE__, 1)) + ->toThrow(TypeError::class, 'positive-int') + ; Config::set(['enabled' => false]); expect(RuntimeTypeChecker::checkStaticProperty(StaticPropFixture::class, 'disabledCount', -50))->toBe(-50); @@ -206,8 +207,8 @@ function runtimeUnconstrainedReturn(): mixed }); test('checkSelfOut executes state transition and handles disabled switch', function () { - $session = new \TypePHP\Tests\TypeChecking\Generics\FixtureSession(); - $target = \TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login'; + $session = new TypePHP\Tests\TypeChecking\Generics\FixtureSession(); + $target = TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login'; RuntimeTypeChecker::checkSelfOut($target, $session); expect(true)->toBeTrue(); @@ -246,7 +247,7 @@ function runtimeUnconstrainedReturn(): mixed }); test('wrapCallable and wrapIterable respect disabled switch', function () { - $cb = fn() => 1; + $cb = fn () => 1; expect(RuntimeTypeChecker::wrapCallable('nonExistent', 'arg', $cb))->toBe($cb); $iter = [1, 2, 3]; @@ -279,47 +280,47 @@ function runtimeUnconstrainedReturn(): mixed $badNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Cat::class)]); $resNode = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode) + fn () => RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode) ); expect($resNode)->toBeNull(); $resVar = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'val', __FILE__) + fn () => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'val', __FILE__) ); expect($resVar)->toBe(-5); $fixture = new ConfiguredProperty(); $resProp = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkProperty(['bad'], $fixture, 'numbers', __FILE__) + fn () => RuntimeTypeChecker::checkProperty(['bad'], $fixture, 'numbers', __FILE__) ); expect($resProp)->toBe(['bad']); $resParams = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkParams(UserService::class . '::find', ['id' => -1], new UserService()) + fn () => RuntimeTypeChecker::checkParams(UserService::class . '::find', ['id' => -1], new UserService()) ); expect($resParams)->toBeNull(); $resOut = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkParamOut('TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', 'id', -50) + fn () => RuntimeTypeChecker::checkParamOut('TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', 'id', -50) ); expect($resOut)->toBeNull(); RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkSelfOut(\TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login', new \TypePHP\Tests\TypeChecking\Generics\FixtureSession()) + fn () => RuntimeTypeChecker::checkSelfOut(TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login', new TypePHP\Tests\TypeChecking\Generics\FixtureSession()) ); $resRet = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkReturn(UserService::class . '::find', ['id' => -1, 'name' => 'Alice'], new UserService()) + fn () => RuntimeTypeChecker::checkReturn(UserService::class . '::find', ['id' => -1, 'name' => 'Alice'], new UserService()) ); expect($resRet)->toBe(['id' => -1, 'name' => 'Alice']); $resSend = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkSend('sampleGeneratorFixture', -50) + fn () => RuntimeTypeChecker::checkSend('sampleGeneratorFixture', -50) ); expect($resSend)->toBe(-50); $resYield = RuntimeCheckerIgnoredCaller::run( - fn() => RuntimeTypeChecker::checkYield('sampleGeneratorFixture', 123, -50) + fn () => RuntimeTypeChecker::checkYield('sampleGeneratorFixture', 123, -50) ); expect($resYield)->toBe(-50); }); @@ -369,59 +370,59 @@ function vendorAction(): void $dog = new Container(new Dog()); $badNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Cat::class)]); - $resNode = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode, $fnName) + $resNode = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::bindInstanceFromNode($dog, $badNode, $fnName) ); expect($resNode)->toBeNull(); - $resVar = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'v', __FILE__, $fnName) + $resVar = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkVariable(-5, 'positive-int', 'v', __FILE__, $fnName) ); expect($resVar)->toBe(-5); - $resProp = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkProperty(['bad'], 'Acme\VendorTest\VendorCaller', 'prop', __FILE__) + $resProp = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkProperty(['bad'], 'Acme\VendorTest\VendorCaller', 'prop', __FILE__) ); expect($resProp)->toBe(['bad']); - $resParams = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkParams($fnName, ['a' => 1]) + $resParams = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkParams($fnName, ['a' => 1]) ); expect($resParams)->toBeNull(); - $resOut = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkParamOut($fnName, 'param', 1) + $resOut = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkParamOut($fnName, 'param', 1) ); expect($resOut)->toBeNull(); - \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkSelfOut($fnName, new stdClass()) + Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkSelfOut($fnName, new stdClass()) ); - $resRet = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkReturn($fnName, 'any') + $resRet = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkReturn($fnName, 'any') ); expect($resRet)->toBe('any'); - $resSend = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkSend($fnName, 'val') + $resSend = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkSend($fnName, 'val') ); expect($resSend)->toBe('val'); - $resYield = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::checkYield($fnName, 'k', 'v') + $resYield = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkYield($fnName, 'k', 'v') ); expect($resYield)->toBe('v'); - $cb = fn() => 1; - $resCb = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::wrapCallable($fnName, 'cb', $cb) + $cb = fn () => 1; + $resCb = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::wrapCallable($fnName, 'cb', $cb) ); expect($resCb)->toBe($cb); $iter = [1]; - $resIter = \Acme\VendorTest\VendorCaller::call( - fn() => RuntimeTypeChecker::wrapIterable($fnName, 'iter', $iter) + $resIter = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::wrapIterable($fnName, 'iter', $iter) ); expect($resIter)->toBe($iter); } finally { diff --git a/tests/Internal/Util/StubManagerTest.php b/tests/Internal/Util/StubManagerTest.php index 33eb5a3..155cabf 100644 --- a/tests/Internal/Util/StubManagerTest.php +++ b/tests/Internal/Util/StubManagerTest.php @@ -111,30 +111,31 @@ function helperFunction(int $code): void $ref = new ReflectionClass(StubManager::class); $method = $ref->getMethod('resolveStubFiles'); - $tempDir = sys_get_temp_dir() . '/typephp_resolve_stubs_' . uniqid(); - mkdir($tempDir, 0777, true); - $dummyFile = $tempDir . '/dummy.stub'; - file_put_contents($dummyFile, 'invoke(null, $dummyFile, $tempDir); - expect($resFile)->toBe([str_replace('\\', '/', $dummyFile)]); + expect($resFile)->toBe([$dummyFile]); - // 2. Directory path without wildcard: is_dir($fullPath) $resDir = $method->invoke(null, $tempDir, $tempDir); - expect($resDir)->toContain(str_replace('\\', '/', $dummyFile)); + expect($resDir)->toContain($dummyFile); - // 3. Wildcard with missing baseDir: !is_dir($baseDir) => return [] $resMissingBase = $method->invoke(null, $tempDir . '/missing_dir_123/sub/*.stub', $tempDir); expect($resMissingBase)->toBe([]); - // 4. Non-existent path without wildcard => falls through to return [] $resMissingFile = $method->invoke(null, $tempDir . '/non_existent_file.stub', $tempDir); expect($resMissingFile)->toBe([]); } finally { - @unlink($dummyFile); - @rmdir($tempDir); + @unlink($targetFile); + @rmdir($baseTemp); } }); @@ -145,7 +146,6 @@ function helperFunction(int $code): void $tempDir = sys_get_temp_dir() . '/typephp_load_stubs_' . uniqid(); mkdir($tempDir, 0777, true); - // Invalid PHP syntax triggers catch (Throwable $e) $badSyntaxFile = $tempDir . '/syntax_error.stub'; file_put_contents($badSyntaxFile, 'parse($code); expect($stmts)->not()->toBeNull(); - // 1. Process interface, trait, and enum statements $method->invoke(null, $stmts); expect(StubManager::hasClassStub('Vendor\AstTest\AstStubInterface'))->toBeTrue() @@ -221,8 +220,6 @@ enum AstStubEnum: string ->and(StubManager::hasClassStub('Vendor\AstTest\AstStubEnum'))->toBeTrue() ->and(StubManager::getClassDoc('Vendor\AstTest\AstStubEnum'))->toContain('Enum doc') ; - - // 2. Anonymous class node triggers `if ($stmt->name === null) continue;` $method->invoke(null, [new Class_(null)]); }); }); diff --git a/tests/Internal/Validator/ArrayShapeValidatorTest.php b/tests/Internal/Validator/ArrayShapeValidatorTest.php index 1fa8248..ac568b1 100644 --- a/tests/Internal/Validator/ArrayShapeValidatorTest.php +++ b/tests/Internal/Validator/ArrayShapeValidatorTest.php @@ -112,7 +112,7 @@ test('supports ConstFetchNode keys in array shape items', function () { $shape = ArrayShapeNode::createSealed([ new ArrayShapeItemNode( - new \PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode('self', 'KEY_NAME'), + new PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode('self', 'KEY_NAME'), false, new IdentifierTypeNode('string') ), diff --git a/tests/Internal/Validator/ArrayValidatorTest.php b/tests/Internal/Validator/ArrayValidatorTest.php index c833dad..53f14cd 100644 --- a/tests/Internal/Validator/ArrayValidatorTest.php +++ b/tests/Internal/Validator/ArrayValidatorTest.php @@ -114,4 +114,4 @@ ->and($errInt->getMessage())->toContain('iter[0]') ; }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Validator/ConstValidatorTest.php b/tests/Internal/Validator/ConstValidatorTest.php index 23721bd..e01e1ed 100644 --- a/tests/Internal/Validator/ConstValidatorTest.php +++ b/tests/Internal/Validator/ConstValidatorTest.php @@ -5,7 +5,6 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFalseNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFloatNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; -use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprTrueNode; @@ -92,7 +91,7 @@ class SampleDefinedClassConst }); test('handles custom or unsupported constant expression fallbacks', function () { - $arrayConstExpr = new \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode([]); + $arrayConstExpr = new PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode([]); $node = new ConstTypeNode($arrayConstExpr); diff --git a/tests/Internal/Validator/IdentifierValidatorTest.php b/tests/Internal/Validator/IdentifierValidatorTest.php index 619fb65..6109a78 100644 --- a/tests/Internal/Validator/IdentifierValidatorTest.php +++ b/tests/Internal/Validator/IdentifierValidatorTest.php @@ -8,9 +8,8 @@ use TypePHP\Internal\Validator\TypeValidatorRegistry; use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; -use TypePHP\Tests\Fixtures\Enums\StatusEnum; -use TypePHP\Tests\Fixtures\Oop\ExecutorTrait; use TypePHP\Tests\Fixtures\Enums\Suit; +use TypePHP\Tests\Fixtures\Oop\ExecutorTrait; describe('IdentifierValidator Unit Tests', function () { beforeEach(function () { @@ -250,7 +249,7 @@ $pureCallable = new IdentifierTypeNode('pure-callable'); $callableNode = new IdentifierTypeNode('callable'); expect($this->registry->validate('strlen', $pureCallable, 'arg'))->toBeNull() - ->and($this->registry->validate(fn() => 1, $callableNode, 'arg'))->toBeNull() + ->and($this->registry->validate(fn () => 1, $callableNode, 'arg'))->toBeNull() ->and($this->registry->validate(12345, $pureCallable, 'arg'))->toBeInstanceOf(ErrorMessage::class) ; }); diff --git a/tests/Internal/Validator/IntersectionValidatorTest.php b/tests/Internal/Validator/IntersectionValidatorTest.php index 1cc5936..09f017b 100644 --- a/tests/Internal/Validator/IntersectionValidatorTest.php +++ b/tests/Internal/Validator/IntersectionValidatorTest.php @@ -22,6 +22,7 @@ class IntersectionDummyUser { public int $id = 10; + public string $name = 'Alice'; } diff --git a/tests/Internal/Validator/ObjectShapeValidatorTest.php b/tests/Internal/Validator/ObjectShapeValidatorTest.php index 7100c4d..37eb527 100644 --- a/tests/Internal/Validator/ObjectShapeValidatorTest.php +++ b/tests/Internal/Validator/ObjectShapeValidatorTest.php @@ -12,12 +12,14 @@ class ObjectShapeTestUser { public int $id = 10; + public string $name = 'Alice'; } class ObjectShapeUninitClass { public int $id; + public string $uninitOptional; } @@ -140,4 +142,4 @@ public function __get(string $name): mixed ->and($errInvalid->getMessage())->toContain('target->badMagicProp') ; }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Wrapper/CallableWrapperTest.php b/tests/Internal/Wrapper/CallableWrapperTest.php index 8bf3b06..b6825bf 100644 --- a/tests/Internal/Wrapper/CallableWrapperTest.php +++ b/tests/Internal/Wrapper/CallableWrapperTest.php @@ -676,4 +676,4 @@ public function __invoke(): void expect(CallableWrapper::isCallable(123))->toBeFalse(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/Wrapper/IterableWrapperTest.php b/tests/Internal/Wrapper/IterableWrapperTest.php index a3dfb28..df31be9 100644 --- a/tests/Internal/Wrapper/IterableWrapperTest.php +++ b/tests/Internal/Wrapper/IterableWrapperTest.php @@ -20,12 +20,16 @@ /** * @param iterable $items */ -function fixtureValidIterableFunction(iterable $items): void {} +function fixtureValidIterableFunction(iterable $items): void +{ +} /** * @param iterable $unparameterized */ -function fixtureBareIterableFunction(iterable $unparameterized): void {} +function fixtureBareIterableFunction(iterable $unparameterized): void +{ +} /** * @return iterable diff --git a/tests/Internal/Wrapper/IteratorProxyTest.php b/tests/Internal/Wrapper/IteratorProxyTest.php index 07fb2ef..3a6cf26 100644 --- a/tests/Internal/Wrapper/IteratorProxyTest.php +++ b/tests/Internal/Wrapper/IteratorProxyTest.php @@ -79,4 +79,4 @@ ->and($proxy->offsetExists('z'))->toBeFalse() ; }); -}); \ No newline at end of file +}); diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index 848f099..15cd524 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -54,6 +54,7 @@ class MultiTemplateNoDefaults /** * @template-covariant T + * * @template-contravariant U */ class MultiVariancePair @@ -62,6 +63,7 @@ class MultiVariancePair /** * @template-covariant A + * * @template-contravariant B */ class MultiVarianceNoDefaults @@ -180,4 +182,4 @@ class MultiVarianceNoDefaults $noDefVar = new MultiVarianceNoDefaults(); expect(TypePHP::getGenericVariance($noDefVar))->toBe('invariant'); }); -}); \ No newline at end of file +}); From c2fb988114150cb1e639aa314bc586b8176a6bc8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:16:39 +0800 Subject: [PATCH 14/17] make typep cli binary to type check any file without specifying in the config --- src/Internal/Cli/RunCommand.php | 14 +++++++++++++- typephp.php | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Internal/Cli/RunCommand.php b/src/Internal/Cli/RunCommand.php index a6050e1..bb38d11 100644 --- a/src/Internal/Cli/RunCommand.php +++ b/src/Internal/Cli/RunCommand.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal\Cli; +use TypePHP\Internal\Util\Config; use TypePHP\TypePHP; /** @@ -54,8 +55,19 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE } try { - TypePHP::boot(); $realTarget = realpath($target); + if ($realTarget !== false) { + $normalizedTarget = str_replace('\\', '/', $realTarget); + $currentConfig = Config::get(); + $includes = (array) ($currentConfig['include'] ?? []); + + Config::set([ + 'include' => array_values(array_unique([...$includes, $normalizedTarget])), + ]); + } + + TypePHP::boot(); + if ($realTarget !== false) { require $realTarget; } diff --git a/typephp.php b/typephp.php index 896101c..0aaeea2 100644 --- a/typephp.php +++ b/typephp.php @@ -144,7 +144,6 @@ | You can use "*" glob to match any file. */ 'include' => [ - '*', 'app/**', 'internals/**', 'tests/**', From 5fd13e4a4956e0e326417e7e0ceb14e742084270 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:25:24 +0800 Subject: [PATCH 15/17] Add wildcard include pattern to configuration for broader file matching --- typephp.php | 1 + 1 file changed, 1 insertion(+) diff --git a/typephp.php b/typephp.php index 0aaeea2..896101c 100644 --- a/typephp.php +++ b/typephp.php @@ -144,6 +144,7 @@ | You can use "*" glob to match any file. */ 'include' => [ + '*', 'app/**', 'internals/**', 'tests/**', From 0b73868bf782f32fcdb9f6323bc35198683c4e1d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:34:45 +0800 Subject: [PATCH 16/17] Refactor include handling in RunCommand to ensure only valid strings are added --- src/Internal/Cli/RunCommand.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Internal/Cli/RunCommand.php b/src/Internal/Cli/RunCommand.php index bb38d11..0f0e09a 100644 --- a/src/Internal/Cli/RunCommand.php +++ b/src/Internal/Cli/RunCommand.php @@ -59,10 +59,20 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE if ($realTarget !== false) { $normalizedTarget = str_replace('\\', '/', $realTarget); $currentConfig = Config::get(); - $includes = (array) ($currentConfig['include'] ?? []); + $includes = []; + + if (\is_array($currentConfig['include'] ?? null)) { + foreach ($currentConfig['include'] as $inc) { + if (\is_string($inc)) { + $includes[] = $inc; + } + } + } + + $includes[] = $normalizedTarget; Config::set([ - 'include' => array_values(array_unique([...$includes, $normalizedTarget])), + 'include' => array_values(array_unique($includes)), ]); } From fb999d2eefa4cb781755664fda80ee450809ca43 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 24 Sep 2026 15:39:22 +0800 Subject: [PATCH 17/17] Update README.md to clarify CLI binary type-checking capabilities for standalone scripts --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6f397c5..9beccee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +Here is the updated `README.md` reflecting that the CLI binary automatically type-checks any specified script without needing to register it in `typephp.php`: + +```markdown

TypePHP

@@ -76,23 +79,27 @@ Simply run your tests or use your local development server (**Laravel**, **Symfo ## Executing Standalone Scripts (CLI Runner) -For standalone, single-execution PHP scripts that do not have an explicit autoloader entry point, run them directly with the TypePHP CLI binary: +You can run any standalone PHP script directly with the TypePHP CLI binary: ```bash vendor/bin/typephp script.php ``` +Targeting a file directly with the CLI binary automatically type-checks that file—**even if it is not registered in `typephp.php` or falls outside your configured `include` paths**. + +Any secondary files required or included by your script will continue to respect your project's configured `include` and `exclude` paths. + --- ## Type-Checking Files Anywhere (`"*"` Wildcard Glob) -By default, TypePHP checks standard application folders (`src/**`, `app/**`, `tests/**`). To type-check PHP files anywhere in your project root while still respecting your excluded folders, set the `"*"` wildcard glob in `typephp.php`: +By default, TypePHP checks standard application folders (`src/**`, `app/**`, `tests/**`). To type-check PHP files anywhere in your project root during regular application runs while still respecting your excluded folders, set the `"*"` wildcard glob in `typephp.php`: ```php // typephp.php return [ 'include' => [ - '*', // Intercepts and type-checks PHP files anywhere in the project + '*', ], 'exclude' => [ 'vendor/**', @@ -167,14 +174,14 @@ Define generic templates and TypePHP tracks their state per object instance in m class Collection { /** @param T $item */ - public function add(mixed $item): void { /* ... */ } + public function add(mixed $item): void { } } // Prebind T = User to this specific instance in WeakMap memory /** @var Collection $users */ $users = new Collection(); -$users->add(new User('Alice')); // Valid +$users->add(new User('Alice')); $users->add(new Product('SKU-100')); // Throws TypeError: Argument $item (template T = User) must be of type User, Product given @@ -207,7 +214,7 @@ class DatabaseService $service = new DatabaseService(); -$service->connect(['driver' => 'pdo_mysql']); // Valid +$service->connect(['driver' => 'pdo_mysql']); $service->connect(['driver' => 'pdo_invalid']); // Throws TypeError: Argument $params['driver'] must be a key of DriverManager::DRIVER_MAP