diff --git a/.gitignore b/.gitignore
index 8ec4fdf3..5b39fba8 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/README.md b/README.md
index 6f397c53..9beccee1 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
diff --git a/phpunit.xml b/phpunit.xml
index c3ba3119..484c56f5 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/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php
index 54625247..8b075050 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/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php
index ff067f17..990170d5 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,10 +275,6 @@ 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 {
public bool $isGen = false;
@@ -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;
}
diff --git a/src/Internal/Cli/RunCommand.php b/src/Internal/Cli/RunCommand.php
index a6050e11..0f0e09ae 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,29 @@ 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 = [];
+
+ 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)),
+ ]);
+ }
+
+ TypePHP::boot();
+
if ($realTarget !== false) {
require $realTarget;
}
diff --git a/src/Internal/Diagnostic/ErrorFactory.php b/src/Internal/Diagnostic/ErrorFactory.php
index 0d9b8a50..bf0148b0 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/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php
index 4004858c..cabd02b5 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)) {
diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php
index 1e60b101..4119826c 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/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php
index 56e97a23..b598de03 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());
}
}
diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php
index 6fbd2728..32840123 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/src/Internal/Util/StubManager.php b/src/Internal/Util/StubManager.php
index 25a497e9..4d257df7 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/src/Internal/Validator/ArrayShapeValidator.php b/src/Internal/Validator/ArrayShapeValidator.php
index e28aa803..58cbf4bb 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) {
diff --git a/src/Internal/Validator/ConstValidator.php b/src/Internal/Validator/ConstValidator.php
index 6b790cec..cd1f0b11 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,17 +104,13 @@ 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
}
}
diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php
index 479ea905..ade27417 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 {
@@ -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/src/Internal/Wrapper/IteratorProxy.php b/src/Internal/Wrapper/IteratorProxy.php
index 381193dd..c613d498 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/Ast/ContractVisitorTest.php b/tests/Internal/Ast/ContractVisitorTest.php
index 05ec72e7..90c786c9 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;');
+ });
});
diff --git a/tests/Internal/Ast/FunctionContractInjectorTest.php b/tests/Internal/Ast/FunctionContractInjectorTest.php
index 8e32573e..accfded4 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);
+ });
});
diff --git a/tests/Internal/Ast/PropertyHookInjectorTest.php b/tests/Internal/Ast/PropertyHookInjectorTest.php
index e61fb9bc..012b124a 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 () {
diff --git a/tests/Internal/Ast/ScopeManagerTest.php b/tests/Internal/Ast/ScopeManagerTest.php
index 43ca448a..e64cab4f 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();
+ });
});
diff --git a/tests/Internal/Diagnostic/ErrorFactoryTest.php b/tests/Internal/Diagnostic/ErrorFactoryTest.php
index bfe9b361..7999cd7a 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,6 +20,49 @@
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);
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 de582f55..3473360d 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');
+});
diff --git a/tests/Internal/RuntimeTypeCheckerTest.php b/tests/Internal/RuntimeTypeCheckerTest.php
index 54348ab6..2074f63f 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;
+
+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,58 @@
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 +150,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/Util/ClassNameValidatorTest.php b/tests/Internal/Util/ClassNameValidatorTest.php
index b79e6cf8..0b4d3937 100644
--- a/tests/Internal/Util/ClassNameValidatorTest.php
+++ b/tests/Internal/Util/ClassNameValidatorTest.php
@@ -2,41 +2,105 @@
declare(strict_types=1);
+namespace TypePHP\Tests\Internal\Util;
+
+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();
+ });
});
});
diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php
index fd9cc84a..32d9808b 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,40 @@
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()
+ ;
+ });
});
diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php
index 179d4cdb..2ed6b667 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();
- });
- });
});
diff --git a/tests/Internal/Util/PathMatcherTest.php b/tests/Internal/Util/PathMatcherTest.php
index c35231fd..a5f32f93 100644
--- a/tests/Internal/Util/PathMatcherTest.php
+++ b/tests/Internal/Util/PathMatcherTest.php
@@ -44,6 +44,13 @@
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')
@@ -222,6 +229,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 +264,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([
diff --git a/tests/Internal/Util/StubManagerTest.php b/tests/Internal/Util/StubManagerTest.php
index c4d82d59..155cabf8 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,125 @@ 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');
+
+ $baseTemp = sys_get_temp_dir() . '/typephp_resolve_stubs_' . uniqid();
+ mkdir($baseTemp, 0777, true);
+ $realTemp = realpath($baseTemp);
+ $tempDir = str_replace('\\', '/', $realTemp !== false ? $realTemp : $baseTemp);
+
+ $targetFile = $tempDir . '/dummy.stub';
+ file_put_contents($targetFile, 'invoke(null, $dummyFile, $tempDir);
+ expect($resFile)->toBe([$dummyFile]);
+
+ $resDir = $method->invoke(null, $tempDir, $tempDir);
+ expect($resDir)->toContain($dummyFile);
+
+ $resMissingBase = $method->invoke(null, $tempDir . '/missing_dir_123/sub/*.stub', $tempDir);
+ expect($resMissingBase)->toBe([]);
+
+ $resMissingFile = $method->invoke(null, $tempDir . '/non_existent_file.stub', $tempDir);
+ expect($resMissingFile)->toBe([]);
+ } finally {
+ @unlink($targetFile);
+ @rmdir($baseTemp);
}
});
+
+ 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);
+
+ $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();
+
+ $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')
+ ;
+ $method->invoke(null, [new Class_(null)]);
+ });
});
diff --git a/tests/Internal/Validator/ArrayShapeValidatorTest.php b/tests/Internal/Validator/ArrayShapeValidatorTest.php
new file mode 100644
index 00000000..ac568b18
--- /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 00000000..53f14cda
--- /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]')
+ ;
+ });
+});
diff --git a/tests/Internal/Validator/ConstValidatorTest.php b/tests/Internal/Validator/ConstValidatorTest.php
new file mode 100644
index 00000000..e01e1ed0
--- /dev/null
+++ b/tests/Internal/Validator/ConstValidatorTest.php
@@ -0,0 +1,117 @@
+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 00000000..e06a78c4
--- /dev/null
+++ b/tests/Internal/Validator/GenericValidatorTest.php
@@ -0,0 +1,439 @@
+ 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 () {
+ beforeEach(function () {
+ $this->registry = new TypeValidatorRegistry();
+ $this->validator = new GenericValidator();
+ });
+
+ afterEach(function () {
+ Config::reset();
+ });
+
+ 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('self::SOME_KEY', $shape, 'key', $this->registry))->toBeNull();
+ expect($this->validator->validate('other', $shape, 'key', $this->registry))->toBeInstanceOf(ErrorMessage::class);
+ });
+
+ 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(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)
+ ;
+
+ $globalValueOf = new GenericTypeNode(new IdentifierTypeNode('value-of'), [
+ new ConstTypeNode(new ConstFetchNode('', 'TYPEPHP_TEST_GLOBAL_MAP')),
+ ]);
+ 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 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('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)
+ ;
+
+ $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(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 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()
+ ->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)
+ ;
+ });
+
+ 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('anything', $scalarConstKeyOf, 'key', $this->registry))->toBeNull();
+ });
+
+ 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('anything', $scalarConstValueOf, 'val', $this->registry))->toBeNull();
+
+ $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()
+ ->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 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)
+ ;
+
+ $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()
+ ->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()
+ ->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 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)
+ ->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 = [];
+ 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] = 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 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);
+
+ $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'),
+ new IdentifierTypeNode('mixed'),
+ ]);
+ expect($this->validator->validate(['a' => 'anything', 1 => 42], $unconstrainedMap, 'map', $this->registry))->toBeNull();
+
+ $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;
+ }
+ $genericMap = new GenericTypeNode(new IdentifierTypeNode('array'), [
+ new IdentifierTypeNode('string'),
+ new IdentifierTypeNode('positive-int'),
+ ]);
+ expect($this->validator->validate($largeMap, $genericMap, 'map', $this->registry))->toBeNull();
+
+ $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 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)
+ ->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()
+ ;
+ });
+});
diff --git a/tests/Internal/Validator/IdentifierValidatorTest.php b/tests/Internal/Validator/IdentifierValidatorTest.php
new file mode 100644
index 00000000..6109a78f
--- /dev/null
+++ b/tests/Internal/Validator/IdentifierValidatorTest.php
@@ -0,0 +1,345 @@
+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 00000000..09f017b0
--- /dev/null
+++ b/tests/Internal/Validator/IntersectionValidatorTest.php
@@ -0,0 +1,226 @@
+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/ObjectShapeValidatorTest.php b/tests/Internal/Validator/ObjectShapeValidatorTest.php
new file mode 100644
index 00000000..37eb5277
--- /dev/null
+++ b/tests/Internal/Validator/ObjectShapeValidatorTest.php
@@ -0,0 +1,145 @@
+ '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')
+ ;
+ });
+});
diff --git a/tests/Internal/Validator/UnionValidatorTest.php b/tests/Internal/Validator/UnionValidatorTest.php
new file mode 100644
index 00000000..be7163ae
--- /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/Internal/Wrapper/CallableWrapperTest.php b/tests/Internal/Wrapper/CallableWrapperTest.php
index e033d654..b6825bfb 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();
+ });
});
});
diff --git a/tests/Internal/Wrapper/IterableWrapperTest.php b/tests/Internal/Wrapper/IterableWrapperTest.php
new file mode 100644
index 00000000..df31be9f
--- /dev/null
+++ b/tests/Internal/Wrapper/IterableWrapperTest.php
@@ -0,0 +1,327 @@
+ $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 bd003118..3a6cf266 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);
diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php
index df6e26a4..15cd5243 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,50 @@ 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 +110,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 +121,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 +129,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 +137,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');
});
});
diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php
deleted file mode 100644
index 2c1dd875..00000000
--- 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();
- });
-});