From 0d7af38ddb1cbbc853c759b14f78750cc9df6ea0 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 16:22:39 +0800 Subject: [PATCH 1/8] Fix cache poisononing in stream wrapper --- src/Internal/StreamWrapper.php | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index bdf95b4..fb415e5 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,10 @@ 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 +245,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,7 +259,7 @@ 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 { @@ -265,7 +267,10 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ - $handle = self::silent(fn () => fopen($targetFile, $mode)); + $handle = self::silent(fn () => ($this->context !== null) + ? fopen($targetFile, $mode, false, $this->context) + : fopen($targetFile, $mode) + ); $this->handle = $handle !== false ? $handle : null; self::register(); @@ -397,8 +402,7 @@ 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). + * 2. Static negative cache ($staticNegativeStatCache): Caches false lookups strictly for immutable vendor paths. * * @return array|false */ @@ -423,7 +427,7 @@ public function url_stat(string $path, int $flags): array|false return self::$statCache[$normalized] = $result; } - if (! PathMatcher::isDynamicWritablePath($normalized)) { + if (PathMatcher::isVendorPath($normalized)) { self::$staticNegativeStatCache[$normalized] = true; } @@ -657,7 +661,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; @@ -726,4 +732,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} +} \ No newline at end of file From 6ec6e1c4df639af459da3c1f5c82d3e114d3e8c0 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 16:35:43 +0800 Subject: [PATCH 2/8] Differentiate :stat vs :lstat in $statCache. --- src/Internal/StreamWrapper.php | 61 ++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index fb415e5..4fceb7d 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,15 +219,16 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn () => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; + $exists = (bool) self::silent(fn() => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ - $handle = self::silent(fn () => ($this->context !== null) - ? fopen($target, $mode, false, $this->context) - : 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(); @@ -264,17 +265,19 @@ private static function isReadOnlyCall(): bool 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 () => ($this->context !== null) - ? fopen($targetFile, $mode, false, $this->context) - : 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(); - if ($this->handle === null && ! $isInclude) { + if ($this->handle === null && ! $isInclude && ($options & STREAM_REPORT_ERRORS) !== 0) { trigger_error("fopen({$targetFile}): Failed to open stream: No such file or directory", E_USER_WARNING); } @@ -406,12 +409,21 @@ public function stream_close(): void * * @return array|false */ + /** + * Resolves file status with dual-tier memoization caching: + * 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])) { @@ -420,11 +432,16 @@ 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::isVendorPath($normalized)) { @@ -446,11 +463,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn () => @chmod($path, $mode)); + $result = (bool) self::silent(fn() => @chmod($path, $mode)); } self::register(); @@ -461,7 +478,7 @@ 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() => @opendir($path)); $this->dirHandle = $dh !== false ? $dh : null; self::register(); @@ -504,7 +521,7 @@ public function mkdir(string $path, int $mode, int $options): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn () => @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); + $result = (bool) self::silent(fn() => @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); self::register(); return $result; @@ -516,7 +533,7 @@ public function rmdir(string $path, int $options): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn () => @rmdir($path)); + $result = (bool) self::silent(fn() => @rmdir($path)); self::register(); return $result; @@ -528,7 +545,7 @@ public function unlink(string $path): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn () => @unlink($path)); + $result = (bool) self::silent(fn() => @unlink($path)); self::register(); return $result; @@ -546,7 +563,7 @@ public function rename(string $pathFrom, string $pathTo): bool ); self::unregister(); - $result = (bool) self::silent(fn () => @rename($pathFrom, $pathTo)); + $result = (bool) self::silent(fn() => @rename($pathFrom, $pathTo)); self::register(); return $result; @@ -563,7 +580,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn () => true); + set_error_handler(static fn() => true); try { return $callback(); @@ -732,4 +749,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} \ No newline at end of file +} From d032bdd4b012e118b93fa5e9e83e0478bf756fcc Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 17:09:23 +0800 Subject: [PATCH 3/8] Update src/Internal/StreamWrapper.php so all filesystem mutation methods (unlink, rmdir, mkdir, rename, stream_metadata) invalidate both :stat and :lstat keys, and pass $this->context to native operations. --- src/Internal/StreamWrapper.php | 90 +++++++++++------- tests/Internal/StreamWrapperTest.php | 134 +++++++++++++++++++++++---- 2 files changed, 171 insertions(+), 53 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 4fceb7d..c8e7e9b 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,16 +219,15 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn() => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ - $handle = self::silent( - fn() => ($this->context !== null) - ? fopen($target, $mode, false, $this->context) - : 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(); @@ -269,10 +268,9 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ - $handle = self::silent( - fn() => ($this->context !== null) - ? fopen($targetFile, $mode, $useIncludePath, $this->context) - : fopen($targetFile, $mode, $useIncludePath) + $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(); @@ -402,13 +400,6 @@ 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 immutable vendor paths. - * - * @return array|false - */ /** * Resolves file status with dual-tier memoization caching: * 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK). @@ -432,7 +423,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn() => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -454,7 +445,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; @@ -463,11 +458,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn() => @chmod($path, $mode)); + $result = (bool) self::silent(fn () => @chmod($path, $mode)); } self::register(); @@ -478,7 +473,10 @@ 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(); @@ -518,10 +516,17 @@ 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 = (bool) self::silent(fn () => ($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; @@ -530,10 +535,17 @@ 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 = (bool) self::silent(fn () => ($this->context !== null) + ? @rmdir($path, $this->context) + : @rmdir($path) + ); self::register(); return $result; @@ -542,10 +554,17 @@ 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 = (bool) self::silent(fn () => ($this->context !== null) + ? @unlink($path, $this->context) + : @unlink($path) + ); self::register(); return $result; @@ -556,14 +575,19 @@ 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 = (bool) self::silent(fn () => ($this->context !== null) + ? @rename($pathFrom, $pathTo, $this->context) + : @rename($pathFrom, $pathTo) + ); self::register(); return $result; @@ -580,7 +604,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn() => true); + set_error_handler(static fn () => true); try { return $callback(); @@ -749,4 +773,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} +} \ No newline at end of file diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 10a3241..58d84f0 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,83 @@ 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 +199,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 +225,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 +245,7 @@ function testIgnoredFileFunc(int $id): int }); }); - describe('stream_open() Fast-Paths & Whitelist Preservation', function () { - afterEach(function () { - Config::reset(); - }); - + describe('stream_open() & Context Support', function () { test('bypasses AST transformation on non-PHP files', function () { $wrapper = new StreamWrapper(); $openedPath = null; @@ -178,6 +259,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 +322,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([ @@ -423,4 +517,4 @@ function dedicatedStreamAction(int $id): int } }); }); -}); +}); \ No newline at end of file From 931d5f4c9ac0b46c23f698562153c2c5010284a5 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 17:37:11 +0800 Subject: [PATCH 4/8] Add tests for native filesystem error passthrough and mutations in StreamWrapper --- src/Internal/StreamWrapper.php | 63 ++++++++++++++-------------- tests/Internal/StreamWrapperTest.php | 52 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index c8e7e9b..d4e4856 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,15 +219,16 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn () => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; + $exists = (bool) self::silent(fn() => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ - $handle = self::silent(fn () => ($this->context !== null) - ? fopen($target, $mode, false, $this->context) - : 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(); @@ -268,14 +269,15 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ - $handle = self::silent(fn () => ($this->context !== null) - ? fopen($targetFile, $mode, $useIncludePath, $this->context) - : fopen($targetFile, $mode, $useIncludePath) + $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(); - if ($this->handle === null && ! $isInclude && ($options & STREAM_REPORT_ERRORS) !== 0) { + if ($this->handle === null && ! $isInclude) { trigger_error("fopen({$targetFile}): Failed to open stream: No such file or directory", E_USER_WARNING); } @@ -423,7 +425,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(fn() => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -458,11 +460,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn () => @chmod($path, $mode)); + $result = (bool) self::silent(fn() => @chmod($path, $mode)); } self::register(); @@ -473,9 +475,10 @@ public function dir_opendir(string $path, int $options): bool { self::unregister(); /** @var resource|false $dh */ - $dh = self::silent(fn () => ($this->context !== null) - ? @opendir($path, $this->context) - : @opendir($path) + $dh = self::silent( + fn() => ($this->context !== null) + ? @opendir($path, $this->context) + : @opendir($path) ); $this->dirHandle = $dh !== false ? $dh : null; self::register(); @@ -523,10 +526,9 @@ public function mkdir(string $path, int $mode, int $options): bool ); self::unregister(); - $result = (bool) self::silent(fn () => ($this->context !== null) + $result = ($this->context !== null) ? @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0, $this->context) - : @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0) - ); + : @mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0); self::register(); return $result; @@ -542,10 +544,9 @@ public function rmdir(string $path, int $options): bool ); self::unregister(); - $result = (bool) self::silent(fn () => ($this->context !== null) - ? @rmdir($path, $this->context) - : @rmdir($path) - ); + $result = ($this->context !== null) + ? rmdir($path, $this->context) + : rmdir($path); self::register(); return $result; @@ -561,10 +562,9 @@ public function unlink(string $path): bool ); self::unregister(); - $result = (bool) self::silent(fn () => ($this->context !== null) - ? @unlink($path, $this->context) - : @unlink($path) - ); + $result = ($this->context !== null) + ? unlink($path, $this->context) + : unlink($path); self::register(); return $result; @@ -584,10 +584,9 @@ public function rename(string $pathFrom, string $pathTo): bool ); self::unregister(); - $result = (bool) self::silent(fn () => ($this->context !== null) - ? @rename($pathFrom, $pathTo, $this->context) - : @rename($pathFrom, $pathTo) - ); + $result = ($this->context !== null) + ? rename($pathFrom, $pathTo, $this->context) + : rename($pathFrom, $pathTo); self::register(); return $result; @@ -604,7 +603,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn () => true); + set_error_handler(static fn() => true); try { return $callback(); @@ -773,4 +772,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} \ No newline at end of file +} diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 58d84f0..81e0e90 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -245,6 +245,58 @@ function testIgnoredFileFunc(int $id): int }); }); + 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(); From 3fa8853b31de6ed20aa072cac7d916adddcc698b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 18:51:36 +0800 Subject: [PATCH 5/8] Fix mutable scalar type refinement in return generic contracts --- src/Internal/StreamWrapper.php | 18 ++-- src/Resolver/TemplateManager.php | 10 ++ tests/Internal/StreamWrapperTest.php | 5 +- .../FluentGenericSpecializationTest.php | 92 +++++++++++++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 tests/TypeChecking/Generics/FluentGenericSpecializationTest.php diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index d4e4856..879de9b 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,14 +219,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn() => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($target, $mode, false, $this->context) : fopen($target, $mode) ); @@ -270,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($targetFile, $mode, $useIncludePath, $this->context) : fopen($targetFile, $mode, $useIncludePath) ); @@ -425,7 +425,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn() => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -460,11 +460,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn() => @chmod($path, $mode)); + $result = (bool) self::silent(fn () => @chmod($path, $mode)); } self::register(); @@ -476,7 +476,7 @@ public function dir_opendir(string $path, int $options): bool self::unregister(); /** @var resource|false $dh */ $dh = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? @opendir($path, $this->context) : @opendir($path) ); @@ -603,7 +603,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn() => true); + set_error_handler(static fn () => true); try { return $callback(); diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 81e44f5..7f05ef1 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -627,6 +627,16 @@ private static function bindSingleTemplateArgument( $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); if (! $valid) { + // In return context, allow methods to specialize broad bounds to narrower types + // (e.g. @return static specializing TKey of array-key to int) + 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 81e0e90..664d80d 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -115,9 +115,10 @@ function testIgnoredFileFunc(int $id): int file_put_contents($targetFile, '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) + ; + }); +}); From cb29a884cbffe888708612b243caddc0636944d9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 19:13:53 +0800 Subject: [PATCH 6/8] Enhance TemplateManager to allow specialization of 'mixed' type in return context and add tests for unspecialized generic default objects --- src/Resolver/TemplateManager.php | 12 +++- .../UnspecializedGenericDefaultTest.php | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 7f05ef1..30aef57 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -622,13 +622,19 @@ 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) { - // In return context, allow methods to specialize broad bounds to narrower types - // (e.g. @return static specializing TKey of array-key to int) + 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; 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 From beee8cf3ee74b27ec27802078ebb3313a0394c9b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 19:33:18 +0800 Subject: [PATCH 7/8] In src/Internal/Visitor/FunctionContractInjector.php, detect native : never return types and never inject return statements or trailing return checks into : never functions. --- .../Visitor/FunctionContractInjector.php | 18 +++---- .../Visitor/FunctionContractInjectorTest.php | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) 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/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 */'); From 57a6081415cb13d0abac6d3ea13f52643f56eb0c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 19:46:48 +0800 Subject: [PATCH 8/8] Enhance ParamChecker to conditionally pre-infer generic templates based on callable parameters; add tests for heterogeneous array handling and higher-order functions. --- src/Internal/Checker/ParamChecker.php | 15 ++++ .../HeterogeneousArrayGenericsTest.php | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php 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/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']"); + }); +});