diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 94ca9ab..d12cf5b 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; @@ -230,6 +231,7 @@ private static function handleMagicCall( /** * Pre-infers generic template parameters from array arguments before callback wrapping. + * Only runs if at least one parameter in the signature is a callable that uses generic templates. * * @param array $types * @param array $vars @@ -242,6 +244,19 @@ private static function preInferGenericArrayTemplates( ?object $thisObj, array $templates ): void { + $hasCallableParam = false; + + foreach ($types as $tNode) { + if ($tNode instanceof CallableTypeNode) { + $hasCallableParam = true; + break; + } + } + + if (! $hasCallableParam) { + return; + } + foreach ($types as $paramName => $typeNode) { if (! \array_key_exists($paramName, $vars) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) { continue; diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index bdf95b4..879de9b 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -53,8 +53,7 @@ final class StreamWrapper implements StreamWrapperInterface private static array $statCache = []; /** - * In-memory cache for static-path negative misses only (e.g. vendor directories). - * Never stores negative misses for dynamic paths (var/cache, storage). + * In-memory cache for static-path negative misses only (vendor directories). * * @var array */ @@ -226,7 +225,11 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ - $handle = self::silent(fn () => fopen($target, $mode)); + $handle = self::silent( + fn () => ($this->context !== null) + ? fopen($target, $mode, false, $this->context) + : fopen($target, $mode) + ); $this->handle = $handle !== false ? $handle : null; self::register(); @@ -243,7 +246,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ } /** - * Very Important: Determines whether the stream is being opened by a native PHP source-viewing function + * Determines whether the stream is being opened by a native PHP source-viewing function * (e.g. highlight_file, show_source, file_get_contents, token_get_all) by inspecting shallow backtrace frames. */ private static function isReadOnlyCall(): bool @@ -257,15 +260,20 @@ private static function isReadOnlyCall(): bool } /** - * Opens an underlying filesystem handle directly with error reporting options. + * Opens an underlying filesystem handle directly with error reporting options and context support. */ private function openDirectHandle(string $targetFile, string $mode, int $options): bool { $isInclude = ($options & self::STREAM_OPEN_FOR_INCLUDE) !== 0; + $useIncludePath = ($options & STREAM_USE_PATH) !== 0; self::unregister(); /** @var resource|false $handle */ - $handle = self::silent(fn () => fopen($targetFile, $mode)); + $handle = self::silent( + fn () => ($this->context !== null) + ? fopen($targetFile, $mode, $useIncludePath, $this->context) + : fopen($targetFile, $mode, $useIncludePath) + ); $this->handle = $handle !== false ? $handle : null; self::register(); @@ -396,18 +404,19 @@ public function stream_close(): void /** * Resolves file status with dual-tier memoization caching: - * 1. Positive hit cache ($statCache): Stores stat arrays for confirmed files. - * 2. Static negative cache ($staticNegativeStatCache): Caches false lookups strictly for static vendor paths. - * 3. Dynamic writable bypass: Bypasses negative caching for dynamic directories (var/cache, storage). + * 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK). + * 2. Only memoizes .php source files and immutable vendor paths. * * @return array|false */ public function url_stat(string $path, int $flags): array|false { $normalized = str_replace('\\', '/', $path); + $isLink = ($flags & STREAM_URL_STAT_LINK) !== 0; + $cacheKey = $normalized . ($isLink ? ':lstat' : ':stat'); - if (isset(self::$statCache[$normalized])) { - return self::$statCache[$normalized]; + if (isset(self::$statCache[$cacheKey])) { + return self::$statCache[$cacheKey]; } if (isset(self::$staticNegativeStatCache[$normalized])) { @@ -416,14 +425,19 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn () => (($flags & STREAM_URL_STAT_LINK) !== 0) ? @lstat($path) : @stat($path)); + $result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { - return self::$statCache[$normalized] = $result; + $isPhp = str_ends_with(strtolower($normalized), '.php'); + if ($isPhp || PathMatcher::isVendorPath($normalized)) { + self::$statCache[$cacheKey] = $result; + } + + return $result; } - if (! PathMatcher::isDynamicWritablePath($normalized)) { + if (PathMatcher::isVendorPath($normalized)) { self::$staticNegativeStatCache[$normalized] = true; } @@ -433,7 +447,11 @@ public function url_stat(string $path, int $flags): array|false public function stream_metadata(string $path, int $option, mixed $value): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); + unset( + self::$statCache[$normalized . ':stat'], + self::$statCache[$normalized . ':lstat'], + self::$staticNegativeStatCache[$normalized] + ); self::unregister(); $result = false; @@ -457,7 +475,11 @@ public function dir_opendir(string $path, int $options): bool { self::unregister(); /** @var resource|false $dh */ - $dh = self::silent(fn () => @opendir($path)); + $dh = self::silent( + fn () => ($this->context !== null) + ? @opendir($path, $this->context) + : @opendir($path) + ); $this->dirHandle = $dh !== false ? $dh : null; self::register(); @@ -497,10 +519,16 @@ public function dir_closedir(): bool public function mkdir(string $path, int $mode, int $options): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); + unset( + self::$statCache[$normalized . ':stat'], + self::$statCache[$normalized . ':lstat'], + self::$staticNegativeStatCache[$normalized] + ); self::unregister(); - $result = (bool) self::silent(fn () => @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); + $result = ($this->context !== null) + ? @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0, $this->context) + : @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0); self::register(); return $result; @@ -509,10 +537,16 @@ public function mkdir(string $path, int $mode, int $options): bool public function rmdir(string $path, int $options): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); + unset( + self::$statCache[$normalized . ':stat'], + self::$statCache[$normalized . ':lstat'], + self::$staticNegativeStatCache[$normalized] + ); self::unregister(); - $result = (bool) self::silent(fn () => @rmdir($path)); + $result = ($this->context !== null) + ? rmdir($path, $this->context) + : rmdir($path); self::register(); return $result; @@ -521,10 +555,16 @@ public function rmdir(string $path, int $options): bool public function unlink(string $path): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); + unset( + self::$statCache[$normalized . ':stat'], + self::$statCache[$normalized . ':lstat'], + self::$staticNegativeStatCache[$normalized] + ); self::unregister(); - $result = (bool) self::silent(fn () => @unlink($path)); + $result = ($this->context !== null) + ? unlink($path, $this->context) + : unlink($path); self::register(); return $result; @@ -535,14 +575,18 @@ public function rename(string $pathFrom, string $pathTo): bool $normFrom = str_replace('\\', '/', $pathFrom); $normTo = str_replace('\\', '/', $pathTo); unset( - self::$statCache[$normFrom], - self::$statCache[$normTo], + self::$statCache[$normFrom . ':stat'], + self::$statCache[$normFrom . ':lstat'], self::$staticNegativeStatCache[$normFrom], + self::$statCache[$normTo . ':stat'], + self::$statCache[$normTo . ':lstat'], self::$staticNegativeStatCache[$normTo] ); self::unregister(); - $result = (bool) self::silent(fn () => @rename($pathFrom, $pathTo)); + $result = ($this->context !== null) + ? rename($pathFrom, $pathTo, $this->context) + : rename($pathFrom, $pathTo); self::register(); return $result; @@ -657,7 +701,9 @@ private function openCachedStream(string $resolvedPath, string $mode): bool } } - $cacheHandle = fopen($cachedFile, $mode); + $cacheHandle = ($this->context !== null) + ? fopen($cachedFile, $mode, false, $this->context) + : fopen($cachedFile, $mode); $this->handle = $cacheHandle !== false ? $cacheHandle : null; return $this->handle !== null; diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 0ad3cfa..3816ed2 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -31,8 +31,11 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); + $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; + $isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never'; + $hasParam = self::hasParamContracts($docText, $isClassMethod); - $hasReturn = ! $isMagicLifecycle && self::hasReturnContracts($docText, $isClassMethod); + $hasReturn = ! $isMagicLifecycle && ! $isNativeNever && self::hasReturnContracts($docText, $isClassMethod); if (! $hasParam && ! $hasReturn) { return; @@ -136,7 +139,7 @@ private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $n return false; } - $visitor = new class () extends NodeVisitorAbstract { + $visitor = new class() extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -534,10 +537,8 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) - { - } + $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) {} public function enterNode(Node $n): int|Node|null { @@ -591,13 +592,12 @@ public function enterNode(Node $n): int|Node|null private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid, bool $needsReturnVars = false): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract { + $traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract { public function __construct( private Node\Expr $thisArg, private bool $isNativeVoid, private bool $needsReturnVars - ) { - } + ) {} public function enterNode(Node $n): int|array|null { diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 81e44f5..30aef57 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -622,11 +622,27 @@ private static function bindSingleTemplateArgument( $templateName = $templateTag->name; $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; - if (isset($existingBindings[$templateName])) { + if (isset($existingBindings[$templateName])) { $existingTypeNode = $existingBindings[$templateName]; $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); if (! $valid) { + if ($existingTypeNode instanceof IdentifierTypeNode && strtolower($existingTypeNode->name) === 'mixed') { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; + + return null; + } + + if ($isReturnContext && self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; + + return null; + } + return ErrorFactory::createError( $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" ); diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 10a3241..664d80d 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -9,10 +9,12 @@ describe('StreamWrapper Unit Tests', function () { beforeEach(function () { Config::reset(); + StreamWrapper::reset(); }); afterEach(function () { Config::reset(); + StreamWrapper::reset(); }); describe('transformSource()', function () { @@ -89,8 +91,8 @@ function testIgnoredFileFunc(int $id): int }); }); - describe('url_stat() & Smart Negative Caching', function () { - test('caches positive stat results in memory', function () { + describe('url_stat(), Symlinks & Cache Invalidation', function () { + test('caches positive stat results in memory for .php source files', function () { $wrapper = new StreamWrapper(); $existingFile = __FILE__; @@ -102,7 +104,84 @@ function testIgnoredFileFunc(int $id): int ; }); - test('caches negative misses for static vendor paths', function () { + test('differentiates between stat() and lstat() cache keys for symlinks', function () { + $wrapper = new StreamWrapper(); + $tempDir = sys_get_temp_dir() . '/typephp_symlink_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + $targetFile = $tempDir . '/target.php'; + $linkFile = $tempDir . '/link.php'; + + file_put_contents($targetFile, 'url_stat($linkFile, 0); + $lstatResult = $wrapper->url_stat($linkFile, STREAM_URL_STAT_LINK); + + expect($statResult)->toBeArray() + ->and($lstatResult)->toBeArray() + ; + + $ref = new ReflectionClass(StreamWrapper::class); + $prop = $ref->getProperty('statCache'); + $cache = $prop->getValue(); + + $normLink = str_replace('\\', '/', $linkFile); + expect($cache)->toHaveKey($normLink . ':stat') + ->and($cache)->toHaveKey($normLink . ':lstat') + ; + } finally { + @unlink($linkFile); + } + } + + if (file_exists($targetFile)) { + @unlink($targetFile); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + }); + + test('bypasses positive stat caching for non-PHP dynamic test assets (.txt, .log)', function () { + $wrapper = new StreamWrapper(); + $tempDir = sys_get_temp_dir() . '/typephp_txt_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + $txtFile = $tempDir . '/sample.txt'; + file_put_contents($txtFile, 'text content'); + + try { + $stat = $wrapper->url_stat($txtFile, 0); + expect($stat)->toBeArray(); + + $ref = new ReflectionClass(StreamWrapper::class); + $prop = $ref->getProperty('statCache'); + $cache = $prop->getValue(); + + $normTxt = str_replace('\\', '/', $txtFile); + // Non-PHP, non-vendor files are not memoized in statCache + expect($cache)->not()->toHaveKey($normTxt . ':stat'); + } finally { + if (file_exists($txtFile)) { + @unlink($txtFile); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + } + }); + + test('caches negative misses strictly for immutable vendor paths', function () { $wrapper = new StreamWrapper(); $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); $missingVendorFile = $projectRoot . '/vendor/non_existent_package/Missing.php'; @@ -121,25 +200,24 @@ function testIgnoredFileFunc(int $id): int expect($negCache)->toHaveKey($missingVendorFile); }); - test('never caches negative misses for dynamic writable paths (var/cache, storage)', function () { + test('never caches negative misses for application test files and temp directories', function () { $wrapper = new StreamWrapper(); $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); - $missingVarCacheFile = $projectRoot . '/var/cache/test/Container.php'; - - $miss = $wrapper->url_stat($missingVarCacheFile, 0); + $missingAppTempFile = $projectRoot . '/tests/Fixtures/tmp/session.tmp'; + $miss = $wrapper->url_stat($missingAppTempFile, 0); expect($miss)->toBeFalse(); $ref = new ReflectionClass(StreamWrapper::class); $negProp = $ref->getProperty('staticNegativeStatCache'); $negCache = $negProp->getValue(); - expect($negCache)->not()->toHaveKey($missingVarCacheFile); + expect($negCache)->not()->toHaveKey($missingAppTempFile); }); - test('invalidates stat cache upon file mutation operations (mkdir, unlink, rename, touch)', function () { + test('invalidates both :stat and :lstat cache keys upon file mutations (unlink, rmdir, rename, mkdir)', function () { $wrapper = new StreamWrapper(); - $tempDir = sys_get_temp_dir() . '/typephp_stat_test_' . uniqid(); + $tempDir = sys_get_temp_dir() . '/typephp_mutation_test_' . uniqid(); $tempFile = $tempDir . '/test.php'; $wrapper->mkdir($tempDir, 0777, STREAM_MKDIR_RECURSIVE); @@ -148,7 +226,15 @@ function testIgnoredFileFunc(int $id): int $stat = $wrapper->url_stat($tempFile, 0); expect($stat)->toBeArray(); + $normFile = str_replace('\\', '/', $tempFile); + + $ref = new ReflectionClass(StreamWrapper::class); + $prop = $ref->getProperty('statCache'); + + expect($prop->getValue())->toHaveKey($normFile . ':stat'); + $wrapper->stream_metadata($tempFile, STREAM_META_TOUCH, [time(), time()]); + expect($prop->getValue())->not()->toHaveKey($normFile . ':stat'); $renamedFile = $tempDir . '/renamed.php'; $wrapper->rename($tempFile, $renamedFile); @@ -160,11 +246,59 @@ function testIgnoredFileFunc(int $id): int }); }); - describe('stream_open() Fast-Paths & Whitelist Preservation', function () { - afterEach(function () { - Config::reset(); + describe('Native Filesystem Error Passthrough & Mutations', function () { + test('rmdir on non-empty directory emits native Directory not empty warning instead of internal error', function () { + $tempDir = sys_get_temp_dir() . '/typephp_rmdir_passthru_' . uniqid(); + mkdir($tempDir . '/sub', 0777, true); + file_put_contents($tempDir . '/sub/file.txt', 'content'); + + $caughtWarning = ''; + set_error_handler(function (int $errno, string $errstr) use (&$caughtWarning) { + $caughtWarning .= $errstr; + + return true; + }); + + try { + $result = rmdir($tempDir . '/sub'); + } finally { + restore_error_handler(); + } + + expect($result)->toBeFalse() + ->and(strtolower($caughtWarning))->toContain('directory not empty') + ->and(strtolower($caughtWarning))->not()->toContain('internal error') + ; + + @unlink($tempDir . '/sub/file.txt'); + @rmdir($tempDir . '/sub'); + @rmdir($tempDir); }); + test('unlink on non-existent file returns false and emits standard warning without internal error', function () { + $missingFile = sys_get_temp_dir() . '/missing_file_' . uniqid() . '.txt'; + + $caughtWarning = ''; + set_error_handler(function (int $errno, string $errstr) use (&$caughtWarning) { + $caughtWarning .= $errstr; + + return true; + }); + + try { + $result = unlink($missingFile); + } finally { + restore_error_handler(); + } + + expect($result)->toBeFalse() + ->and(strtolower($caughtWarning))->toContain('no such file or directory') + ->and(strtolower($caughtWarning))->not()->toContain('internal error') + ; + }); + }); + + describe('stream_open() & Context Support', function () { test('bypasses AST transformation on non-PHP files', function () { $wrapper = new StreamWrapper(); $openedPath = null; @@ -178,6 +312,23 @@ function testIgnoredFileFunc(int $id): int $wrapper->stream_close(); }); + test('supports stream context options when opening direct handles', function () { + $wrapper = new StreamWrapper(); + $context = stream_context_create([ + 'file' => [ + 'ignore_errors' => true, + ], + ]); + $wrapper->context = $context; + + $openedPath = null; + $target = __FILE__; + + $success = $wrapper->stream_open($target, 'r', 0, $openedPath); + expect($success)->toBeTrue(); + $wrapper->stream_close(); + }); + test('bypasses AST transformation for unwhitelisted vendor files', function () { try { Config::set([ @@ -224,10 +375,6 @@ function testIgnoredFileFunc(int $id): int }); describe('Vendor Subpackage Isolation', function () { - afterEach(function () { - Config::reset(); - }); - test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { try { Config::set([ diff --git a/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php b/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php new file mode 100644 index 0000000..c25ff9e --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php @@ -0,0 +1,69 @@ + $array + * + * @return list + */ +function testHeterogeneousValues(iterable $array): array +{ + return array_values(\is_array($array) ? $array : iterator_to_array($array)); +} + +/** + * Higher-order mapper that DOES have a callable parameter (must still pre-infer V) + * + * @template K of array-key + * @template V + * @template V2 + * + * @param array $array + * @param callable(V): V2 $cb + * + * @return array + */ +function testHigherOrderMap(array $array, callable $cb): array +{ + $out = []; + foreach ($array as $k => $v) { + $out[$k] = $cb($v); + } + + return $out; +} + +describe('Heterogeneous Array Generics in Array Helpers (Tempest values/flatten pattern)', function () { + test('accepts heterogeneous array of objects and strings in standalone array utility function', function () { + $mixedDiscoveredItems = [ + 0 => new Dog(), + 1 => new Dog(), + 2 => 'App\\Models\\DiscoveredEntity', + ]; + + $result = testHeterogeneousValues($mixedDiscoveredItems); + + expect($result)->toHaveCount(3) + ->and($result[0])->toBeInstanceOf(Dog::class) + ->and($result[2])->toBe('App\\Models\\DiscoveredEntity') + ; + }); + + test('higher-order functions with callables still pre-infer template and enforce consistency', function () { + $stringify = fn(int $x): string => "num_{$x}"; + + expect(testHigherOrderMap([10, 20], $stringify))->toBe(['num_10', 'num_20']); + + expect(fn() => testHigherOrderMap([10, 'not_an_int'], $stringify)) + ->toThrow(TypeError::class, "['1']"); + }); +}); diff --git a/tests/TypeChecking/Generics/FluentGenericSpecializationTest.php b/tests/TypeChecking/Generics/FluentGenericSpecializationTest.php new file mode 100644 index 0000000..6e89c5a --- /dev/null +++ b/tests/TypeChecking/Generics/FluentGenericSpecializationTest.php @@ -0,0 +1,92 @@ +items = \is_array($items) ? $items : [$items]; + } + + /** + * Modifies $this in place and returns $this as static + * + * @return static + */ + public function values(): static + { + $this->items = array_values($this->items); + + return $this; // Returns $this which had TKey = array-key! + } + + /** + * Modifies $this in place and returns $this as static + * + * @return static + */ + public function stringKeys(): static + { + $this->items = ['key_1' => reset($this->items)]; + + return $this; + } + + /** + * Method returning invalid specialization violating the existing TValue = Dog binding + * + * @return static + */ + public function badValueSpecialization(): static + { + return $this; + } +} + +describe('Fluent Mutable Generic Return Type Specialization (static)', function () { + test('allows mutable methods like values() to specialize broad TKey of array-key down to int on returned $this instance', function () { + /** @var MutableSpecializedCollection $collection */ + $collection = new MutableSpecializedCollection(['first' => 'Alice', 'second' => 'Bob']); + + $valuesResult = $collection->values(); + + expect($valuesResult)->toBe($collection) + ->and(TypePHP::getGenericType($valuesResult, 'TKey'))->toBe('int') + ->and($valuesResult->items)->toBe(['Alice', 'Bob']) + ; + }); + + test('allows mutable methods to specialize broad TKey down to string on returned $this instance', function () { + /** @var MutableSpecializedCollection $collection */ + $collection = new MutableSpecializedCollection([0 => 'Alice']); + + $stringKeysResult = $collection->stringKeys(); + + expect($stringKeysResult)->toBe($collection) + ->and(TypePHP::getGenericType($stringKeysResult, 'TKey'))->toBe('string') + ; + }); + + test('strictly throws TypeError when return specialization violates underlying value type', function () { + /** @var MutableSpecializedCollection $collection */ + $collection = new MutableSpecializedCollection(); + + expect(fn () => $collection->badValueSpecialization()) + ->toThrow(TypeError::class) + ; + }); +}); diff --git a/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php b/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php new file mode 100644 index 0000000..cbe6d8a --- /dev/null +++ b/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php @@ -0,0 +1,64 @@ + + */ + public array $middlewares = []; +} + +/** + * Function accepting specialized generic container + * + * @param GenericMiddlewareContainer $container + */ +function consumeMiddlewareContainer(GenericMiddlewareContainer $container): bool +{ + return true; +} + +describe('Unspecialized Generic Default Objects & First-Use Parameter Binding', function () { + test('reproduces tempest unspecialized mixed binding mismatch on un-annotated instance', function () { + $rawContainer = new GenericMiddlewareContainer(); + + TemplateManager::bindTemplate('none', $rawContainer, 'TMiddleware', new IdentifierTypeNode('mixed')); + + expect(consumeMiddlewareContainer($rawContainer))->toBeTrue() + ->and(TypePHP::getGenericType($rawContainer))->toBe(SpecificAppMiddleware::class) + ; + }); + + test('strictly rejects explicitly annotated instance holding an incompatible generic type', function () { + $badContainer = new GenericMiddlewareContainer(); + TemplateManager::bindTemplate('none', $badContainer, 'TMiddleware', new IdentifierTypeNode(IncompatibleOtherMiddleware::class)); + + expect(fn () => consumeMiddlewareContainer($badContainer)) + ->toThrow(TypeError::class, 'expects GenericMiddlewareContainer, but GenericMiddlewareContainer was given') + ; + }); +}); \ No newline at end of file diff --git a/tests/Visitor/FunctionContractInjectorTest.php b/tests/Visitor/FunctionContractInjectorTest.php index 6148964..5ccc2b9 100644 --- a/tests/Visitor/FunctionContractInjectorTest.php +++ b/tests/Visitor/FunctionContractInjectorTest.php @@ -16,6 +16,59 @@ Config::reset(); }); + describe('Native : never Return Type Handling', function () { + test('does not inject return statements into methods with native : never return type (Tempest MockClock::dd pattern)', function () { + $method = new Node\Stmt\ClassMethod('dd', [ + 'returnType' => new Node\Identifier('never'), + 'stmts' => [ + new Node\Stmt\Expression(new Node\Expr\FuncCall(new Node\Name('dd'))), + ], + ]); + + FunctionContractInjector::inject($method); + + $hasReturn = false; + foreach ($method->stmts ?? [] as $stmt) { + if ($stmt instanceof Node\Stmt\Return_) { + $hasReturn = true; + } + } + + expect($hasReturn)->toBeFalse(); + }); + + test('transforms code containing native : never methods without injecting return statements into AST', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->not()->toContain('return ($__typephpRet') + ->and($transformed)->not()->toContain('return null;') + ; + }); + }); + describe('Parameter Injections', function () { test('injects setupScope and return check into function with docblocks', function () { $doc = new Doc('/** @param positive-int $id @return non-empty-string */');