diff --git a/.gitignore b/.gitignore index 01e59131..8ec4fdf3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ composer.lock index.php benchmark.php -.php-cs-fixer.cache \ No newline at end of file +.php-cs-fixer.cache +/app \ No newline at end of file diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 745938ad..70082080 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -14,6 +14,7 @@ use TypePHP\Internal\Ast\TypePHPPrinter; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Util\IgnoreManager; use TypePHP\Internal\Util\PathMatcher; /** @@ -178,8 +179,14 @@ public static function unregister(): void */ public static function transformSource(string $source, string $filePath = ''): string { - if (Config::isRespectIgnoreTagsEnabled() && (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file'))) { - return $source; + if (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file')) { + if ($filePath !== '') { + IgnoreManager::registerIgnoredFile($filePath); + } + + if (Config::isRespectIgnoreTagsEnabled()) { + return $source; + } } $originalLineCount = substr_count($source, "\n"); diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 4def22dc..4615f444 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -15,6 +15,7 @@ use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Util\IgnoreManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; use TypePHP\Internal\Wrapper\CallableWrapper; use TypePHP\Internal\Wrapper\IterableWrapper; @@ -26,6 +27,20 @@ final class RuntimeTypeChecker { private static ?TypeValidatorRegistry $registry = null; + /** + * @var array + */ + private static array $hasMethodTemplatesCache = []; + + /** + * Resets runtime caches. + */ + public static function reset(): void + { + self::$hasMethodTemplatesCache = []; + IgnoreManager::reset(); + } + /** * Returns whether TypePHP is globally enabled in configuration. */ @@ -43,7 +58,13 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t return null; } - return TemplateManager::bindInstanceFromNode($instance, $typeNode, $context, $forceBind); + $err = TemplateManager::bindInstanceFromNode($instance, $typeNode, $context, $forceBind); + + if ($err !== null && IgnoreManager::isCallerIgnored()) { + return null; + } + + return $err; } /** @@ -61,7 +82,7 @@ public static function checkVariable( return $value; } - return InlineChecker::checkVariable( + $res = InlineChecker::checkVariable( $value, $typeString, $varName, @@ -70,6 +91,12 @@ public static function checkVariable( $caller, $thisOrClass ); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; } /** @@ -86,13 +113,14 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - return InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); - } + $res = InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); - /** - * @var array - */ - private static array $hasMethodTemplatesCache = []; + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; + } /** * Initialises generic call frames and returns a ScopeCleaner that pops the call frame on destruction. @@ -123,6 +151,10 @@ public static function setupScope(string $function, array $vars, object|string|n $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry(), $effectiveFunction); if ($err !== null) { + if (IgnoreManager::isCallerIgnored()) { + return null; + } + TemplateManager::popCallFrame($effectiveFunction); return $err; @@ -151,7 +183,13 @@ public static function checkParams(string $function, array $vars, object|string| return null; } - return ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); + $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); + + if ($err !== null && IgnoreManager::isCallerIgnored()) { + return null; + } + + return $err; } /** @@ -172,14 +210,19 @@ public static function checkReturn(string $function, mixed $value, object|string $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - // Fast-path: if return type is unconstrained, skip validation. if (ReturnChecker::isReturnUnconstrained($effectiveFunction)) { return $value; } $vars ??= []; - return ReturnChecker::checkReturn($function, $value, $thisOrClass, $vars, self::getRegistry(), [self::class, 'wrapIterable']); + $res = ReturnChecker::checkReturn($function, $value, $thisOrClass, $vars, self::getRegistry(), [self::class, 'wrapIterable']); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; } /** @@ -191,7 +234,13 @@ public static function checkSend(string $function, mixed $sendValue, object|stri return $sendValue; } - return GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); + $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $sendValue; + } + + return $res; } /** @@ -203,7 +252,13 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob return $value; } - return GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); + $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; } /** diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index d2ed4b38..fc8dc023 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -13,6 +13,7 @@ use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; +use TypePHP\Internal\RuntimeTypeChecker; /** * Global configuration manager for loading and dynamically overriding settings. @@ -308,6 +309,8 @@ public static function set(array $config): void StubManager::reset(); SpecialTypeResolver::reset(); CacheManager::reset(); + IgnoreManager::reset(); + RuntimeTypeChecker::reset(); } /** @@ -370,6 +373,8 @@ public static function reset(): void StubManager::reset(); SpecialTypeResolver::reset(); CacheManager::reset(); + IgnoreManager::reset(); + RuntimeTypeChecker::reset(); } /** diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php new file mode 100644 index 00000000..e41a262d --- /dev/null +++ b/src/Internal/Util/IgnoreManager.php @@ -0,0 +1,225 @@ + bool). + * + * @var array + */ + private static array $callerCache = []; + + /** + * In-memory registry of files marked with @typephp-ignore-file. + * + * @var array + */ + private static array $fileCache = []; + + /** + * Resets all ignore caches. + */ + public static function reset(): void + { + self::$callerCache = []; + self::$fileCache = []; + } + + /** + * Registers a file as ignored at the StreamWrapper level during file load. + */ + public static function registerIgnoredFile(string $filePath): void + { + if ($filePath === '') { + return; + } + + $normalized = str_replace('\\', '/', $filePath); + self::$fileCache[$normalized] = true; + } + + /** + * Fast O(1) check if a file has @typephp-ignore-file in memory. + */ + public static function isFileIgnored(string $filePath): bool + { + if ($filePath === '' || self::$fileCache === []) { + return false; + } + + $normalized = str_replace('\\', '/', $filePath); + + return self::$fileCache[$normalized] ?? false; + } + + /** + * Determines whether the calling method, function, or file has @typephp-ignore annotations. + * Only executed when a type validation failure occurs. + */ + public static function isCallerIgnored(?string $callerClass = null, ?string $callerFunction = null): bool + { + if (! Config::isRespectIgnoreTagsEnabled()) { + return false; + } + + if ($callerClass !== null && $callerFunction !== null) { + $key = $callerClass . '::' . $callerFunction; + if (isset(self::$callerCache[$key])) { + return self::$callerCache[$key]; + } + + return self::$callerCache[$key] = self::checkMethodIgnored($callerClass, $callerFunction); + } + + if ($callerFunction !== null) { + if (isset(self::$callerCache[$callerFunction])) { + return self::$callerCache[$callerFunction]; + } + + return self::$callerCache[$callerFunction] = self::checkFunctionIgnored($callerFunction); + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + + for ($i = 1; $i < \count($trace); $i++) { + $frame = $trace[$i]; + $class = $frame['class'] ?? ''; + $function = $frame['function']; + $file = $frame['file'] ?? ''; + + if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) { + continue; + } + + if ($class === '' && \in_array($function, ['call_user_func', 'call_user_func_array'], true)) { + continue; + } + + if ($file !== '' && self::isFileIgnored($file)) { + return true; + } + + if ($class !== '') { + $key = $class . '::' . $function; + if (isset(self::$callerCache[$key])) { + if (self::$callerCache[$key]) { + return true; + } + + continue; + } + + if (self::checkMethodIgnored($class, $function)) { + return self::$callerCache[$key] = true; + } + + self::$callerCache[$key] = false; + } elseif (! str_contains($function, '{closure}')) { + if (isset(self::$callerCache[$function])) { + if (self::$callerCache[$function]) { + return true; + } + + continue; + } + + if (self::checkFunctionIgnored($function)) { + return self::$callerCache[$function] = true; + } + + self::$callerCache[$function] = false; + } + } + + return false; + } + + private static function checkMethodIgnored(string $class, string $method): bool + { + if (StubManager::hasMethodStub($class, $method)) { + $stubDoc = StubManager::getMethodDoc($class, $method); + if ($stubDoc !== null && (str_contains($stubDoc, '@typephp-ignore') || str_contains($stubDoc, '@typephp-disable'))) { + return true; + } + } + + if (StubManager::hasClassStub($class)) { + $classStubDoc = StubManager::getClassDoc($class); + if ($classStubDoc !== null && (str_contains($classStubDoc, '@typephp-ignore') || str_contains($classStubDoc, '@typephp-disable'))) { + return true; + } + } + + if (! class_exists($class) && ! trait_exists($class) && ! interface_exists($class) && ! enum_exists($class)) { + return false; + } + + try { + /** @var class-string $class */ + $refClass = new ReflectionClass($class); + + if ($refClass->hasMethod($method)) { + $refMethod = $refClass->getMethod($method); + $doc = $refMethod->getDocComment(); + if ($doc !== false && $doc !== null && ( + str_contains($doc, '@typephp-ignore') + || str_contains($doc, '@typephp-disable') + )) { + return true; + } + } + + $classDoc = $refClass->getDocComment(); + if ($classDoc !== false && $classDoc !== null && ( + str_contains($classDoc, '@typephp-ignore') + || str_contains($classDoc, '@typephp-disable') + )) { + return true; + } + } catch (Throwable $e) { + return false; + } + + return false; + } + + private static function checkFunctionIgnored(string $function): bool + { + if (StubManager::hasFunctionStub($function)) { + $stubDoc = StubManager::getFunctionDoc($function); + if ($stubDoc !== null && (str_contains($stubDoc, '@typephp-ignore') || str_contains($stubDoc, '@typephp-disable'))) { + return true; + } + } + + if (! \function_exists($function)) { + return false; + } + + try { + $refFunc = new ReflectionFunction($function); + $doc = $refFunc->getDocComment(); + if ($doc !== false && $doc !== null && ( + str_contains($doc, '@typephp-ignore') + || str_contains($doc, '@typephp-disable') + )) { + return true; + } + } catch (Throwable $e) { + return false; + } + + return false; + } +} diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php new file mode 100644 index 00000000..67557f6e --- /dev/null +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -0,0 +1,244 @@ +toBeFalse(); + + IgnoreManager::registerIgnoredFile($path); + IgnoreManager::registerIgnoredFile($windowsPath); + + expect(IgnoreManager::isFileIgnored($path))->toBeTrue() + ->and(IgnoreManager::isFileIgnored('C:/project/app/Services/IgnoredService.php'))->toBeTrue() + ->and(IgnoreManager::isFileIgnored($windowsPath))->toBeTrue() + ->and(IgnoreManager::isFileIgnored('/var/www/app/OtherFile.php'))->toBeFalse() + ; + }); + + test('handles empty file paths gracefully', function () { + expect(IgnoreManager::isFileIgnored(''))->toBeFalse(); + + IgnoreManager::registerIgnoredFile(''); + expect(IgnoreManager::isFileIgnored(''))->toBeFalse(); + }); + + test('clears file registry on reset', function () { + $path = '/var/www/app/Test.php'; + IgnoreManager::registerIgnoredFile($path); + expect(IgnoreManager::isFileIgnored($path))->toBeTrue(); + + IgnoreManager::reset(); + expect(IgnoreManager::isFileIgnored($path))->toBeFalse(); + }); + }); + + describe('Caller Method & Class Ignore Detection (isCallerIgnored)', function () { + test('identifies calling method marked with @typephp-ignore', function () { + expect(IgnoredMethodCallerFixture::executeIgnored())->toBeTrue(); + }); + + test('identifies calling method marked with @typephp-disable alias', function () { + expect(IgnoredMethodCallerFixture::executeDisabled())->toBeTrue(); + }); + + test('identifies calling method when entire declaring class has @typephp-ignore', function () { + expect(IgnoredClassCallerFixture::executeClassIgnored())->toBeTrue(); + }); + + test('returns false for un-annotated normal caller methods', function () { + expect(IgnoredMethodCallerFixture::executeNormal())->toBeFalse() + ->and(NormalCallerFixture::execute())->toBeFalse() + ; + }); + + test('identifies standalone functions marked with @typephp-ignore', function () { + expect(testIgnoredStandaloneCaller())->toBeTrue() + ->and(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() + ; + }); + }); + + describe('Stub-Based Caller Ignore Detection', function () { + beforeEach(function () { + Config::reset(); + IgnoreManager::reset(); + }); + + afterEach(function () { + Config::reset(); + IgnoreManager::reset(); + }); + + test('identifies caller methods ignored via external stub files', function () { + $tempDir = sys_get_temp_dir() . '/typephp_ignore_stub_' . uniqid(); + mkdir($tempDir, 0777, true); + + $stubPath = $tempDir . '/StubIgnoredCallerFixture.stub'; + $stubContent = <<<'PHP' + [ + str_replace('\\', '/', $tempDir) . '/**', + ], + ]); + + expect(StubIgnoredCallerFixture::execute())->toBeTrue(); + } finally { + if (file_exists($stubPath)) { + @unlink($stubPath); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + } + }); + }); + + describe('Config Override (respect_ignore_tags => false)', function () { + test('bypasses ignore tags completely when respect_ignore_tags is disabled in config', function () { + try { + Config::set(['respect_ignore_tags' => false]); + + expect(IgnoredMethodCallerFixture::executeIgnored())->toBeFalse() + ->and(IgnoredClassCallerFixture::executeClassIgnored())->toBeFalse() + ->and(testIgnoredStandaloneCaller())->toBeFalse() + ; + } finally { + Config::reset(); + } + }); + }); + + 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(); + }); + }); +}); \ No newline at end of file diff --git a/typephp.php b/typephp.php index fe2c398b..896101cd 100644 --- a/typephp.php +++ b/typephp.php @@ -148,7 +148,6 @@ 'app/**', 'internals/**', 'tests/**', - // 'vendor/my-org/my-package/**', // Whitelist a vendor package ], /* @@ -160,10 +159,10 @@ | single legacy files inside included directories (e.g. 'src/Legacy/File.php'). */ 'exclude' => [ + 'app/BlacklistClass.php', 'vendor/**', 'storage/**', 'var/**', 'cache/**', - // 'src/Legacy/UnsafeFile.php', // Blacklist a single specific file ], ];