diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index 361a2a1..b92f913 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -23,6 +23,13 @@ final class PathMatcher */ private static array $compiledExcludesCache = []; + /** + * Cache for include prefix lookup decisions. + * + * @var array + */ + private static array $includePrefixCache = []; + /** * Cached normalized cache directory path. */ @@ -40,6 +47,7 @@ public static function reset(): void { self::$compiledIncludesCache = []; self::$compiledExcludesCache = []; + self::$includePrefixCache = []; self::$cachedCacheDir = null; self::$cachedLibSrcDir = null; } @@ -82,8 +90,6 @@ public static function isCachePath(string $normalizedPath): bool /** * Determines whether a path belongs to TypePHP's own internal engine source files. - * In vendor mode: skips the entire library package. - * In development mode: skips only actual internal subdirectories, allowing test fixtures to be tested. */ public static function isLibraryInternal(string $normalizedPath): bool { @@ -125,6 +131,80 @@ public static function isLibraryInternal(string $normalizedPath): bool return false; } + /** + * Fast-checks if an include glob list contains any pattern matching a given prefix. + * + * @param array $includes + */ + public static function hasIncludeMatchingPrefix(string $prefix, array $includes): bool + { + $cacheKey = $prefix . '|' . implode(',', $includes); + if (isset(self::$includePrefixCache[$cacheKey])) { + return self::$includePrefixCache[$cacheKey]; + } + + foreach ($includes as $inc) { + if (\is_string($inc)) { + $norm = str_replace('\\', '/', trim($inc)); + if (str_starts_with($norm, $prefix) || str_contains($norm, '/' . $prefix)) { + return self::$includePrefixCache[$cacheKey] = true; + } + } + } + + return self::$includePrefixCache[$cacheKey] = false; + } + + /** + * Determines whether a directory path is a dynamic writable cache/log directory. + */ + public static function isDynamicWritablePath(string $normalizedPath): bool + { + return str_contains($normalizedPath, '/var/cache/') || str_starts_with($normalizedPath, 'var/cache/') + || str_contains($normalizedPath, '/var/log/') || str_starts_with($normalizedPath, 'var/log/') + || str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/') + || str_contains($normalizedPath, '/cache/') || str_starts_with($normalizedPath, 'cache/'); + } + + /** + * High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included, + * while respecting user whitelists for vendor, var, and storage directories. + */ + public static function mayPathBeIncluded(string $normalizedPath): bool + { + if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) { + return false; + } + + if (self::isCachePath($normalizedPath)) { + return false; + } + + $config = Config::get(); + /** @var array $includes */ + $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; + + if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) { + if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) { + return false; + } + } + + if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) { + if (! self::hasIncludeMatchingPrefix('var/', $includes)) { + return false; + } + } + + if (str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')) { + if (! self::hasIncludeMatchingPrefix('storage/', $includes)) { + return false; + } + } + + return true; + } + /** * Converts a glob pattern into an absolute anchored regex pattern. */ diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 18046ad..0e5d96e 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -48,6 +48,14 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $statCache = []; + /** + * In-memory cache for static-path negative misses only (e.g. vendor). + * Never stores negative misses for dynamic paths (var/cache, storage). + * + * @var array + */ + private static array $staticNegativeStatCache = []; + /** * In-memory cache for isApplicationFile path decisions. * @@ -55,12 +63,27 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $appFileDecisionCache = []; + /** + * Fast-lookup table for read-only source view functions. + * + * @var array + */ + private const READ_ONLY_FUNCTIONS = [ + 'file_get_contents' => true, + 'file' => true, + 'readfile' => true, + 'highlight_file' => true, + 'show_source' => true, + 'token_get_all' => true, + ]; + /** * Resets all internal caches. */ public static function reset(): void { self::$statCache = []; + self::$staticNegativeStatCache = []; self::$appFileDecisionCache = []; PathMatcher::reset(); } @@ -171,26 +194,47 @@ public static function transformSource(string $source, string $filePath = ''): s */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { - // Fast-path: non-PHP files are never transformed + if ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') { + return $this->openDirectHandle($path, $mode); + } + if (! str_ends_with(strtolower($path), '.php')) { return $this->openDirectHandle($path, $mode); } - // Fast-path: unwhitelisted vendor files are never transformed + if (! Config::isEnabled()) { + return $this->openDirectHandle($path, $mode); + } + $normalizedRaw = str_replace('\\', '/', $path); - if (str_contains($normalizedRaw, '/vendor/') || str_starts_with($normalizedRaw, 'vendor/')) { + + if (! PathMatcher::mayPathBeIncluded($normalizedRaw)) { return $this->openDirectHandle($path, $mode); } + if (isset(self::$appFileDecisionCache[$normalizedRaw])) { + if (! self::$appFileDecisionCache[$normalizedRaw]) { + return $this->openDirectHandle($path, $mode); + } + } + self::unregister(); - $exists = self::silent(fn () => file_exists($path)); - $resolvedPath = $exists ? realpath($path) : ''; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; self::register(); - $isAppFile = $exists && ! self::isReadOnlyCall() && self::isApplicationFile($path, $resolvedPath); + if (! $exists || $resolvedPath === false) { + return $this->openDirectHandle($path, $mode); + } + + $normalizedResolved = str_replace('\\', '/', $resolvedPath); + + if (! self::isApplicationFile($path, $resolvedPath)) { + return $this->openDirectHandle($normalizedResolved, $mode); + } - if (! $isAppFile || $resolvedPath === false) { - return $this->openDirectHandle(($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path, $mode); + if (self::isReadOnlyCall()) { + return $this->openDirectHandle($normalizedResolved, $mode); } self::unregister(); @@ -204,6 +248,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $success; } + /** + * Determines if the stream_open call is for reading raw file contents/snippets + * (e.g. error screen renderers) rather than PHP engine execution. + */ + private static function isReadOnlyCall(): bool + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + + $caller1 = strtolower($trace[1]['function'] ?? ''); + $caller2 = strtolower($trace[2]['function'] ?? ''); + + return isset(self::READ_ONLY_FUNCTIONS[$caller1]) || isset(self::READ_ONLY_FUNCTIONS[$caller2]); + } + /** * Opens a raw file handle directly without stream interception. */ @@ -338,25 +396,36 @@ public function stream_close(): void /** * High-speed stat resolution with $O(1)$ memoization cache. - * Never caches false (negative lookups) so newly created directories and files are immediately discovered. + * Caches positive stat hits. + * Only caches negative misses for STATIC directories (vendor, tests). + * NEVER caches negative misses for dynamic writable paths (var/cache, storage), + * guaranteeing Symfony/Shopware cache creation is detected immediately. * * @return array|false */ public function url_stat(string $path, int $flags): array|false { $normalized = str_replace('\\', '/', $path); + if (isset(self::$statCache[$normalized])) { return self::$statCache[$normalized]; } + if (isset(self::$staticNegativeStatCache[$normalized])) { + return false; + } + self::unregister(); /** @var array|false $result */ $result = self::silent(fn () => stat($path)); self::register(); - // Only cache positive results (existing files/dirs) if ($result !== false) { self::$statCache[$normalized] = $result; + } else { + if (! PathMatcher::isDynamicWritablePath($normalized)) { + self::$staticNegativeStatCache[$normalized] = true; + } } return $result; @@ -365,7 +434,7 @@ 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]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = false; @@ -429,10 +498,10 @@ public function dir_closedir(): bool public function mkdir(string $path, int $mode, int $options): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn () => mkdir($path, $mode, (bool) ($options & STREAM_MKDIR_RECURSIVE))); + $result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); self::register(); return $result; @@ -441,7 +510,7 @@ 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]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = (bool) self::silent(fn () => rmdir($path)); @@ -453,7 +522,7 @@ public function rmdir(string $path, int $options): bool public function unlink(string $path): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = (bool) self::silent(fn () => unlink($path)); @@ -466,7 +535,12 @@ public function rename(string $pathFrom, string $pathTo): bool { $normFrom = str_replace('\\', '/', $pathFrom); $normTo = str_replace('\\', '/', $pathTo); - unset(self::$statCache[$normFrom], self::$statCache[$normTo]); + unset( + self::$statCache[$normFrom], + self::$statCache[$normTo], + self::$staticNegativeStatCache[$normFrom], + self::$staticNegativeStatCache[$normTo] + ); self::unregister(); $result = (bool) self::silent(fn () => rename($pathFrom, $pathTo)); @@ -475,18 +549,6 @@ public function rename(string $pathFrom, string $pathTo): bool return $result; } - /** - * Determines if the current stream_open call is directly for reading file contents - * rather than PHP engine's require/include execution. - */ - private static function isReadOnlyCall(): bool - { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); - $callerFunc = strtolower($trace[2]['function'] ?? ''); - - return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], true); - } - /** * Executes a callback while temporarily suppressing PHP error and warning handlers. * diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php index 14f04e6..e730387 100644 --- a/tests/Internal/PathMatcherTest.php +++ b/tests/Internal/PathMatcherTest.php @@ -11,10 +11,12 @@ describe('PathMatcher Unit Tests', function () { beforeEach(function () { Config::reset(); + PathMatcher::reset(); }); afterEach(function () { Config::reset(); + PathMatcher::reset(); }); describe('normalizePath()', function () { @@ -88,6 +90,91 @@ }); }); + describe('hasIncludeMatchingPrefix()', function () { + test('detects when include list has patterns starting with prefix', function () { + $includes = ['src/**', 'vendor/my-org/my-pkg/**', 'app/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('vendor/', $includes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('src/', $includes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('var/', $includes))->toBeFalse() + ->and(PathMatcher::hasIncludeMatchingPrefix('storage/', $includes))->toBeFalse() + ; + }); + + test('detects scoped or subpath prefixes in include list', function () { + $includes = ['packages/custom/var/plugins/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('var/', $includes))->toBeTrue(); + }); + }); + + describe('isDynamicWritablePath()', function () { + test('identifies dynamic writable cache and log directories', function () { + expect(PathMatcher::isDynamicWritablePath('/var/www/var/cache/prod/Container.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('var/cache/test/app.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('var/log/dev.log'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('/project/storage/framework/views/123.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('storage/logs/laravel.log'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('/tmp/cache/item.php'))->toBeTrue() + ; + }); + + test('returns false for static read-only directories', function () { + expect(PathMatcher::isDynamicWritablePath('/var/www/src/Core/Service.php'))->toBeFalse() + ->and(PathMatcher::isDynamicWritablePath('/var/www/vendor/doctrine/dbal/Column.php'))->toBeFalse() + ->and(PathMatcher::isDynamicWritablePath('tests/Unit/SampleTest.php'))->toBeFalse() + ; + }); + }); + + describe('mayPathBeIncluded() Fast-Path String Pre-Filter', function () { + test('rejects node_modules and TypePHP cache unconditionally', function () { + $cacheDir = PathMatcher::normalizePath(CacheManager::getCacheDir()); + + expect(PathMatcher::mayPathBeIncluded('/var/www/node_modules/vue/index.js'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('node_modules/package/file.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded($cacheDir . '/v0.1_test.php'))->toBeFalse() + ; + }); + + test('rejects unwhitelisted vendor, var, and storage paths when config does not include them', function () { + try { + Config::set([ + 'include' => ['src/**', 'app/**'], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/monolog/monolog/src/Logger.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/vendor/symfony/console/App.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/cache/Container.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('storage/framework/views/1.php'))->toBeFalse() + ; + } finally { + Config::reset(); + } + }); + + test('permits vendor, var, or storage paths when explicitly whitelisted in include config', function () { + try { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/my-org/my-package/**', + 'var/plugins/**', + 'storage/custom/**', + ], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/my-org/my-package/src/Service.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/plugins/Plugin.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('storage/custom/Handler.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('src/App/Controller.php'))->toBeTrue() + ; + } finally { + Config::reset(); + } + }); + }); + describe('compileGlobToRegex()', function () { test('compiles absolute glob patterns into exact anchored regex', function () { $baseDir = '/var/www/project'; @@ -185,8 +272,9 @@ }); describe('reset()', function () { - test('clears internal pattern and directory caches cleanly', function () { + test('clears internal pattern, prefix, and directory caches cleanly', function () { PathMatcher::normalizePath('test/path'); + PathMatcher::hasIncludeMatchingPrefix('vendor/', ['vendor/**']); PathMatcher::isCachePath('some/path'); PathMatcher::reset(); @@ -194,4 +282,4 @@ expect(true)->toBeTrue(); }); }); -}); +}); \ No newline at end of file diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index a650986..1247d8d 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -2,13 +2,25 @@ declare(strict_types=1); +namespace TypePHP\Tests\Internal; + +use ReflectionClass; use TypePHP\Contract\FileFilter; use TypePHP\Internal\Config; use TypePHP\Internal\StreamWrapper; describe('StreamWrapper Unit Tests', function () { - test('transformSource transforms functions and injects RuntimeTypeChecker checks', function () { - $source = <<<'PHP' + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('transformSource()', function () { + test('transforms functions and injects RuntimeTypeChecker checks', function () { + $source = <<<'PHP' toContain('RuntimeTypeChecker::setupScope') - ->and($transformed)->toContain('testUser') - ; - }); + expect($transformed)->toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->toContain('testUser') + ; + }); - test('transformSource returns raw source unchanged if source is not valid PHP', function () { - $invalidSource = 'toBe($invalidSource); - }); + expect($transformed)->toBe($invalidSource); + }); - test('transformSource wraps yield expressions in generator functions', function () { - $genSource = <<<'PHP' + test('wraps yield expressions in generator functions', function () { + $genSource = <<<'PHP' toContain('RuntimeTypeChecker::checkYield') + ->and($transformed)->toContain('RuntimeTypeChecker::checkSend') + ; + }); + + test('respects @typephp-ignore-file docblock suppression tag', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkYield') - ->and($transformed)->toContain('RuntimeTypeChecker::checkSend') - ; + expect($transformed)->not()->toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->toBe($source) + ; + }); }); - test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { - Config::set([ - 'include' => [ - 'src/**', - 'src/Core/**', - 'src/Storefront/**', - 'src/Administration/**', - ], - 'exclude' => [ - 'vendor/**', - 'storage/**', - 'var/**', - 'cache/**', - ], - ]); - - $projectRoot = Config::getProjectRoot(); - - $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); - $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); - - expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() - ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() - ; + describe('url_stat() & Smart Negative Caching', function () { + test('caches positive stat results in memory', function () { + $wrapper = new StreamWrapper(); + $existingFile = __FILE__; - Config::reset(); + $stat1 = $wrapper->url_stat($existingFile, 0); + $stat2 = $wrapper->url_stat($existingFile, 0); + + expect($stat1)->toBeArray() + ->and($stat1)->toBe($stat2) + ; + }); + + test('caches negative misses for static vendor paths', function () { + $wrapper = new StreamWrapper(); + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $missingVendorFile = $projectRoot . '/vendor/non_existent_package/Missing.php'; + + $miss1 = $wrapper->url_stat($missingVendorFile, 0); + $miss2 = $wrapper->url_stat($missingVendorFile, 0); + + expect($miss1)->toBeFalse() + ->and($miss2)->toBeFalse() + ; + + $ref = new ReflectionClass(StreamWrapper::class); + $negProp = $ref->getProperty('staticNegativeStatCache'); + $negCache = $negProp->getValue(); + + expect($negCache)->toHaveKey($missingVendorFile); + }); + + test('never caches negative misses for dynamic writable paths (var/cache, storage)', function () { + $wrapper = new StreamWrapper(); + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $missingVarCacheFile = $projectRoot . '/var/cache/test/Container.php'; + + $miss = $wrapper->url_stat($missingVarCacheFile, 0); + + expect($miss)->toBeFalse(); + + $ref = new ReflectionClass(StreamWrapper::class); + $negProp = $ref->getProperty('staticNegativeStatCache'); + $negCache = $negProp->getValue(); + + expect($negCache)->not()->toHaveKey($missingVarCacheFile); + }); + + test('invalidates stat cache upon file mutation operations (mkdir, unlink, rename, touch)', function () { + $wrapper = new StreamWrapper(); + $tempDir = sys_get_temp_dir() . '/typephp_stat_test_' . uniqid(); + $tempFile = $tempDir . '/test.php'; + + $wrapper->mkdir($tempDir, 0777, STREAM_MKDIR_RECURSIVE); + file_put_contents($tempFile, 'url_stat($tempFile, 0); + expect($stat)->toBeArray(); + + $wrapper->stream_metadata($tempFile, STREAM_META_TOUCH, [time(), time()]); + + $renamedFile = $tempDir . '/renamed.php'; + $wrapper->rename($tempFile, $renamedFile); + + $wrapper->unlink($renamedFile); + $wrapper->rmdir($tempDir, 0); + + expect(true)->toBeTrue(); + }); + }); + + describe('stream_open() Fast-Paths & Whitelist Preservation', function () { + afterEach(function () { + Config::reset(); + }); + + test('bypasses AST transformation on non-PHP files', function () { + $wrapper = new StreamWrapper(); + $openedPath = null; + $jsonFile = __DIR__ . '/../../composer.json'; + + $success = $wrapper->stream_open($jsonFile, 'r', 0, $openedPath); + expect($success)->toBeTrue(); + + $content = $wrapper->stream_read(1000); + expect($content)->toContain('"name": "typephp/typephp"'); + $wrapper->stream_close(); + }); + + test('bypasses AST transformation for unwhitelisted vendor files', function () { + try { + Config::set([ + 'include' => ['src/**'], + 'exclude' => ['vendor/**'], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $vendorFile = $projectRoot . '/vendor/composer/autoload_real.php'; + + if (file_exists($vendorFile)) { + $wrapper = new StreamWrapper(); + $openedPath = null; + $success = $wrapper->stream_open($vendorFile, 'r', 0, $openedPath); + + expect($success)->toBeTrue(); + $wrapper->stream_close(); + } + } finally { + Config::reset(); + } + }); + + test('transforms whitelisted vendor files when explicitly included in config', function () { + try { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/monolog/monolog/src/Monolog/Logger.php', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $whitelistedVendorFile = $projectRoot . '/vendor/monolog/monolog/src/Monolog/Logger.php'; + + expect(FileFilter::isFileExcluded($whitelistedVendorFile))->toBeFalse(); + } finally { + Config::reset(); + } + }); + }); + + 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([ + 'include' => [ + 'src/**', + 'src/Core/**', + 'src/Storefront/**', + 'src/Administration/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + + $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); + $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); + + expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() + ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() + ; + } finally { + Config::reset(); + } + }); }); -}); +}); \ No newline at end of file