From a85a29c28e33c839dff72dff9fb6c43d925a333e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 8 Sep 2026 18:29:35 +0800 Subject: [PATCH 1/4] Implement IgnoreManager for handling @typephp-ignore tags and enhance runtime type checking to improve testing experience --- src/Internal/Io/StreamWrapper.php | 29 +-- src/Internal/RuntimeTypeChecker.php | 43 +++-- src/Internal/Util/Config.php | 3 + src/Internal/Util/IgnoreManager.php | 225 ++++++++++++++++++++++ tests/Internal/Util/IgnoreManagerTest.php | 223 +++++++++++++++++++++ 5 files changed, 495 insertions(+), 28 deletions(-) create mode 100644 src/Internal/Util/IgnoreManager.php create mode 100644 tests/Internal/Util/IgnoreManagerTest.php diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 745938ad..8c17906b 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"); @@ -316,14 +323,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(static fn () => file_exists($path)); - $resolvedPath = $exists ? self::silent(static fn () => realpath($path)) : false; + $exists = (bool) self::silent(static fn() => file_exists($path)); + $resolvedPath = $exists ? self::silent(static 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) ); @@ -367,7 +374,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) ); @@ -522,7 +529,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(static fn () => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -559,11 +566,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(); @@ -575,7 +582,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) ); @@ -702,7 +709,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/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 4def22dc..f2923719 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. */ @@ -39,7 +54,7 @@ public static function isEnabled(): bool */ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return null; } @@ -57,7 +72,7 @@ public static function checkVariable( ?string $caller = null, mixed $thisOrClass = null ): mixed { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $value; } @@ -82,18 +97,13 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $value; } return InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); } - /** - * @var array - */ - private static array $hasMethodTemplatesCache = []; - /** * Initialises generic call frames and returns a ScopeCleaner that pops the call frame on destruction. * @@ -109,7 +119,7 @@ public static function setupScope(string $function, array $vars, object|string|n return null; } - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return null; } @@ -147,7 +157,7 @@ public static function setupScope(string $function, array $vars, object|string|n */ public static function checkParams(string $function, array $vars, object|string|null $thisOrClass = null): ?ErrorMessage { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return null; } @@ -165,14 +175,13 @@ public static function checkReturn(string $function, mixed $value, object|string return $value; } - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $value; } $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; } @@ -187,7 +196,7 @@ public static function checkReturn(string $function, mixed $value, object|string */ public static function checkSend(string $function, mixed $sendValue, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $sendValue; } @@ -199,7 +208,7 @@ public static function checkSend(string $function, mixed $sendValue, object|stri */ public static function checkYield(string $function, mixed $key, mixed $value, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $value; } @@ -211,7 +220,7 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob */ public static function wrapCallable(string $function, string $paramName, mixed $callable, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $callable; } @@ -223,7 +232,7 @@ public static function wrapCallable(string $function, string $paramName, mixed $ */ public static function wrapIterable(string $function, string $paramName, mixed $iterable, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled()) { + if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { return $iterable; } @@ -271,4 +280,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} +} \ No newline at end of file diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index d2ed4b38..c6e71f5b 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. @@ -370,6 +371,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..e566a664 --- /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. + * Supports optional explicit caller overrides for direct inspection. + */ + 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 !== '' && $function !== '') { + $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 ($function !== '' && ! 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; + } +} \ No newline at end of file diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php new file mode 100644 index 00000000..ad2425b3 --- /dev/null +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -0,0 +1,223 @@ +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 () { + 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 . '/NormalCallerFixture.stub'; + $stubContent = <<<'PHP' + [ + str_replace('\\', '/', $tempDir) . '/**', + ], + ]); + + expect(NormalCallerFixture::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 From da087d817bc09941e1d200993d6f730c11457212 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 9 Sep 2026 13:03:27 +0800 Subject: [PATCH 2/4] Refactor IgnoreManager and RuntimeTypeChecker for improved clarity and performance; update .gitignore and typephp.php configurations --- .gitignore | 3 +- src/Internal/Io/StreamWrapper.php | 18 ++--- src/Internal/RuntimeTypeChecker.php | 82 ++++++++++++++++++----- src/Internal/Util/IgnoreManager.php | 10 +-- tests/Internal/Util/IgnoreManagerTest.php | 2 +- typephp.php | 3 +- 6 files changed, 82 insertions(+), 36 deletions(-) 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 8c17906b..70082080 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -323,14 +323,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(static fn() => file_exists($path)); - $resolvedPath = $exists ? self::silent(static fn() => realpath($path)) : false; + $exists = (bool) self::silent(static fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(static 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) ); @@ -374,7 +374,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) ); @@ -529,7 +529,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -566,11 +566,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(); @@ -582,7 +582,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) ); @@ -709,7 +709,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/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index f2923719..4615f444 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -54,11 +54,17 @@ public static function isEnabled(): bool */ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { 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; } /** @@ -72,11 +78,11 @@ public static function checkVariable( ?string $caller = null, mixed $thisOrClass = null ): mixed { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return $value; } - return InlineChecker::checkVariable( + $res = InlineChecker::checkVariable( $value, $typeString, $varName, @@ -85,6 +91,12 @@ public static function checkVariable( $caller, $thisOrClass ); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; } /** @@ -97,11 +109,17 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return $value; } - return InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); + $res = InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + return $value; + } + + return $res; } /** @@ -119,7 +137,7 @@ public static function setupScope(string $function, array $vars, object|string|n return null; } - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return null; } @@ -133,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; @@ -157,11 +179,17 @@ public static function setupScope(string $function, array $vars, object|string|n */ public static function checkParams(string $function, array $vars, object|string|null $thisOrClass = null): ?ErrorMessage { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { 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; } /** @@ -175,7 +203,7 @@ public static function checkReturn(string $function, mixed $value, object|string return $value; } - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return $value; } @@ -188,7 +216,13 @@ public static function checkReturn(string $function, mixed $value, object|string $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; } /** @@ -196,11 +230,17 @@ public static function checkReturn(string $function, mixed $value, object|string */ public static function checkSend(string $function, mixed $sendValue, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { + return $sendValue; + } + + $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $sendValue; } - return GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); + return $res; } /** @@ -208,11 +248,17 @@ public static function checkSend(string $function, mixed $sendValue, object|stri */ public static function checkYield(string $function, mixed $key, mixed $value, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { + return $value; + } + + $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); + + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $value; } - return GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); + return $res; } /** @@ -220,7 +266,7 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob */ public static function wrapCallable(string $function, string $paramName, mixed $callable, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return $callable; } @@ -232,7 +278,7 @@ public static function wrapCallable(string $function, string $paramName, mixed $ */ public static function wrapIterable(string $function, string $paramName, mixed $iterable, object|string|null $thisOrClass = null): mixed { - if (! Config::isEnabled() || IgnoreManager::isCallerIgnored()) { + if (! Config::isEnabled()) { return $iterable; } @@ -280,4 +326,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} \ No newline at end of file +} diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php index e566a664..e41a262d 100644 --- a/src/Internal/Util/IgnoreManager.php +++ b/src/Internal/Util/IgnoreManager.php @@ -65,7 +65,7 @@ public static function isFileIgnored(string $filePath): bool /** * Determines whether the calling method, function, or file has @typephp-ignore annotations. - * Supports optional explicit caller overrides for direct inspection. + * Only executed when a type validation failure occurs. */ public static function isCallerIgnored(?string $callerClass = null, ?string $callerFunction = null): bool { @@ -95,7 +95,7 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal for ($i = 1; $i < \count($trace); $i++) { $frame = $trace[$i]; $class = $frame['class'] ?? ''; - $function = $frame['function'] ?? ''; + $function = $frame['function']; $file = $frame['file'] ?? ''; if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) { @@ -110,7 +110,7 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal return true; } - if ($class !== '' && $function !== '') { + if ($class !== '') { $key = $class . '::' . $function; if (isset(self::$callerCache[$key])) { if (self::$callerCache[$key]) { @@ -125,7 +125,7 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal } self::$callerCache[$key] = false; - } elseif ($function !== '' && ! str_contains($function, '{closure}')) { + } elseif (! str_contains($function, '{closure}')) { if (isset(self::$callerCache[$function])) { if (self::$callerCache[$function]) { return true; @@ -222,4 +222,4 @@ private static function checkFunctionIgnored(string $function): bool return false; } -} \ No newline at end of file +} diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php index ad2425b3..2b0cfddb 100644 --- a/tests/Internal/Util/IgnoreManagerTest.php +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -220,4 +220,4 @@ public static function execute(): bool 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 ], ]; From 5f10c031647bac4b1cbef79e4e82684d89e7050b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 9 Sep 2026 15:58:28 +0800 Subject: [PATCH 3/4] Enhance IgnoreManager tests with dedicated stub-based caller and reset logic --- src/Internal/Util/Config.php | 7 ++---- tests/Internal/Util/IgnoreManagerTest.php | 29 +++++++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index c6e71f5b..8207d33b 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -272,11 +272,6 @@ public static function get(): array return self::$cachedConfig = $mergedConfig; } - /** - * Overrides the current configuration at runtime. - * - * @param array $config - */ public static function set(array $config): void { $current = self::$cachedConfig ?? self::get(); @@ -309,6 +304,8 @@ public static function set(array $config): void StubManager::reset(); SpecialTypeResolver::reset(); CacheManager::reset(); + IgnoreManager::reset(); + RuntimeTypeChecker::reset(); } /** diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php index 2b0cfddb..67557f6e 100644 --- a/tests/Internal/Util/IgnoreManagerTest.php +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -58,6 +58,17 @@ public static function execute(): bool } } +/** + * Fixture: Dedicated caller for stub-based ignore testing + */ +class StubIgnoredCallerFixture +{ + public static function execute(): bool + { + return IgnoreManager::isCallerIgnored(); + } +} + /** * Standalone function with ignore tag * @@ -155,17 +166,27 @@ function testNormalStandaloneCaller(): bool }); 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 . '/NormalCallerFixture.stub'; + $stubPath = $tempDir . '/StubIgnoredCallerFixture.stub'; $stubContent = <<<'PHP' toBeTrue(); + expect(StubIgnoredCallerFixture::execute())->toBeTrue(); } finally { if (file_exists($stubPath)) { @unlink($stubPath); @@ -220,4 +241,4 @@ public static function execute(): bool expect(NormalCallerFixture::execute())->toBeFalse(); }); }); -}); +}); \ No newline at end of file From 25ab20ac8956c3e098bb19be8f29de100296df2f Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 9 Sep 2026 16:01:39 +0800 Subject: [PATCH 4/4] Add runtime configuration override method to Config class --- src/Internal/Util/Config.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 8207d33b..fc8dc023 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -272,6 +272,11 @@ public static function get(): array return self::$cachedConfig = $mergedConfig; } + /** + * Overrides the current configuration at runtime. + * + * @param array $config + */ public static function set(array $config): void { $current = self::$cachedConfig ?? self::get();